feat: persist system settings and refine admin layouts
This commit is contained in:
@@ -1,155 +1,66 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.datasource import DataSource
|
||||
from app.models.task import CollectionTask
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.core.security import get_current_user
|
||||
from app.services.collectors.registry import collector_registry
|
||||
from app.models.user import User
|
||||
from app.services.scheduler import run_collector_now, sync_datasource_job
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
COLLECTOR_INFO = {
|
||||
"top500": {
|
||||
"id": 1,
|
||||
"name": "TOP500 Supercomputers",
|
||||
"module": "L1",
|
||||
"priority": "P0",
|
||||
"frequency_hours": 4,
|
||||
},
|
||||
"epoch_ai_gpu": {
|
||||
"id": 2,
|
||||
"name": "Epoch AI GPU Clusters",
|
||||
"module": "L1",
|
||||
"priority": "P0",
|
||||
"frequency_hours": 6,
|
||||
},
|
||||
"huggingface_models": {
|
||||
"id": 3,
|
||||
"name": "HuggingFace Models",
|
||||
"module": "L2",
|
||||
"priority": "P1",
|
||||
"frequency_hours": 12,
|
||||
},
|
||||
"huggingface_datasets": {
|
||||
"id": 4,
|
||||
"name": "HuggingFace Datasets",
|
||||
"module": "L2",
|
||||
"priority": "P1",
|
||||
"frequency_hours": 12,
|
||||
},
|
||||
"huggingface_spaces": {
|
||||
"id": 5,
|
||||
"name": "HuggingFace Spaces",
|
||||
"module": "L2",
|
||||
"priority": "P2",
|
||||
"frequency_hours": 24,
|
||||
},
|
||||
"peeringdb_ixp": {
|
||||
"id": 6,
|
||||
"name": "PeeringDB IXP",
|
||||
"module": "L2",
|
||||
"priority": "P1",
|
||||
"frequency_hours": 24,
|
||||
},
|
||||
"peeringdb_network": {
|
||||
"id": 7,
|
||||
"name": "PeeringDB Networks",
|
||||
"module": "L2",
|
||||
"priority": "P2",
|
||||
"frequency_hours": 48,
|
||||
},
|
||||
"peeringdb_facility": {
|
||||
"id": 8,
|
||||
"name": "PeeringDB Facilities",
|
||||
"module": "L2",
|
||||
"priority": "P2",
|
||||
"frequency_hours": 48,
|
||||
},
|
||||
"telegeography_cables": {
|
||||
"id": 9,
|
||||
"name": "Submarine Cables",
|
||||
"module": "L2",
|
||||
"priority": "P1",
|
||||
"frequency_hours": 168,
|
||||
},
|
||||
"telegeography_landing": {
|
||||
"id": 10,
|
||||
"name": "Cable Landing Points",
|
||||
"module": "L2",
|
||||
"priority": "P2",
|
||||
"frequency_hours": 168,
|
||||
},
|
||||
"telegeography_systems": {
|
||||
"id": 11,
|
||||
"name": "Cable Systems",
|
||||
"module": "L2",
|
||||
"priority": "P2",
|
||||
"frequency_hours": 168,
|
||||
},
|
||||
"arcgis_cables": {
|
||||
"id": 15,
|
||||
"name": "ArcGIS Submarine Cables",
|
||||
"module": "L2",
|
||||
"priority": "P1",
|
||||
"frequency_hours": 168,
|
||||
},
|
||||
"arcgis_landing_points": {
|
||||
"id": 16,
|
||||
"name": "ArcGIS Landing Points",
|
||||
"module": "L2",
|
||||
"priority": "P1",
|
||||
"frequency_hours": 168,
|
||||
},
|
||||
"arcgis_cable_landing_relation": {
|
||||
"id": 17,
|
||||
"name": "ArcGIS Cable-Landing Relations",
|
||||
"module": "L2",
|
||||
"priority": "P1",
|
||||
"frequency_hours": 168,
|
||||
},
|
||||
"fao_landing_points": {
|
||||
"id": 18,
|
||||
"name": "FAO Landing Points",
|
||||
"module": "L2",
|
||||
"priority": "P1",
|
||||
"frequency_hours": 168,
|
||||
},
|
||||
"spacetrack_tle": {
|
||||
"id": 19,
|
||||
"name": "Space-Track TLE",
|
||||
"module": "L3",
|
||||
"priority": "P2",
|
||||
"frequency_hours": 24,
|
||||
},
|
||||
"celestrak_tle": {
|
||||
"id": 20,
|
||||
"name": "CelesTrak TLE",
|
||||
"module": "L3",
|
||||
"priority": "P2",
|
||||
"frequency_hours": 24,
|
||||
},
|
||||
}
|
||||
|
||||
ID_TO_COLLECTOR = {info["id"]: name for name, info in COLLECTOR_INFO.items()}
|
||||
COLLECTOR_TO_ID = {name: info["id"] for name, info in COLLECTOR_INFO.items()}
|
||||
def format_frequency_label(minutes: int) -> str:
|
||||
if minutes % 1440 == 0:
|
||||
return f"{minutes // 1440}d"
|
||||
if minutes % 60 == 0:
|
||||
return f"{minutes // 60}h"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def get_collector_name(source_id: str) -> Optional[str]:
|
||||
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
|
||||
datasource = None
|
||||
try:
|
||||
numeric_id = int(source_id)
|
||||
if numeric_id in ID_TO_COLLECTOR:
|
||||
return ID_TO_COLLECTOR[numeric_id]
|
||||
datasource = await db.get(DataSource, int(source_id))
|
||||
except ValueError:
|
||||
pass
|
||||
if source_id in COLLECTOR_INFO:
|
||||
return source_id
|
||||
return None
|
||||
|
||||
if datasource is not None:
|
||||
return datasource
|
||||
|
||||
result = await db.execute(
|
||||
select(DataSource).where(
|
||||
(DataSource.source == source_id) | (DataSource.collector_class == source_id)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_last_completed_task(db: AsyncSession, datasource_id: int) -> Optional[CollectionTask]:
|
||||
result = await db.execute(
|
||||
select(CollectionTask)
|
||||
.where(CollectionTask.datasource_id == datasource_id)
|
||||
.where(CollectionTask.completed_at.isnot(None))
|
||||
.order_by(CollectionTask.completed_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[CollectionTask]:
|
||||
result = await db.execute(
|
||||
select(CollectionTask)
|
||||
.where(CollectionTask.datasource_id == datasource_id)
|
||||
.where(CollectionTask.status == "running")
|
||||
.order_by(CollectionTask.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -160,48 +71,24 @@ async def list_datasources(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
query = select(DataSource)
|
||||
|
||||
filters = []
|
||||
query = select(DataSource).order_by(DataSource.module, DataSource.id)
|
||||
if module:
|
||||
filters.append(DataSource.module == module)
|
||||
query = query.where(DataSource.module == module)
|
||||
if is_active is not None:
|
||||
filters.append(DataSource.is_active == is_active)
|
||||
query = query.where(DataSource.is_active == is_active)
|
||||
if priority:
|
||||
filters.append(DataSource.priority == priority)
|
||||
|
||||
if filters:
|
||||
query = query.where(*filters)
|
||||
query = query.where(DataSource.priority == priority)
|
||||
|
||||
result = await db.execute(query)
|
||||
datasources = result.scalars().all()
|
||||
|
||||
collector_list = []
|
||||
for name, info in COLLECTOR_INFO.items():
|
||||
is_active_status = collector_registry.is_active(name)
|
||||
|
||||
running_task_query = (
|
||||
select(CollectionTask)
|
||||
.where(CollectionTask.datasource_id == info["id"])
|
||||
.where(CollectionTask.status == "running")
|
||||
.order_by(CollectionTask.started_at.desc())
|
||||
.limit(1)
|
||||
for datasource in datasources:
|
||||
running_task = await get_running_task(db, datasource.id)
|
||||
last_task = await get_last_completed_task(db, datasource.id)
|
||||
data_count_result = await db.execute(
|
||||
select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
|
||||
)
|
||||
running_result = await db.execute(running_task_query)
|
||||
running_task = running_result.scalar_one_or_none()
|
||||
|
||||
last_run_query = (
|
||||
select(CollectionTask)
|
||||
.where(CollectionTask.datasource_id == info["id"])
|
||||
.where(CollectionTask.completed_at.isnot(None))
|
||||
.order_by(CollectionTask.completed_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
last_run_result = await db.execute(last_run_query)
|
||||
last_task = last_run_result.scalar_one_or_none()
|
||||
|
||||
data_count_query = select(func.count(CollectedData.id)).where(CollectedData.source == name)
|
||||
data_count_result = await db.execute(data_count_query)
|
||||
data_count = data_count_result.scalar() or 0
|
||||
|
||||
last_run = None
|
||||
@@ -210,13 +97,14 @@ async def list_datasources(
|
||||
|
||||
collector_list.append(
|
||||
{
|
||||
"id": info["id"],
|
||||
"name": info["name"],
|
||||
"module": info["module"],
|
||||
"priority": info["priority"],
|
||||
"frequency": f"{info['frequency_hours']}h",
|
||||
"is_active": is_active_status,
|
||||
"collector_class": name,
|
||||
"id": datasource.id,
|
||||
"name": datasource.name,
|
||||
"module": datasource.module,
|
||||
"priority": datasource.priority,
|
||||
"frequency": format_frequency_label(datasource.frequency_minutes),
|
||||
"frequency_minutes": datasource.frequency_minutes,
|
||||
"is_active": datasource.is_active,
|
||||
"collector_class": datasource.collector_class,
|
||||
"last_run": last_run,
|
||||
"is_running": running_task is not None,
|
||||
"task_id": running_task.id if running_task else None,
|
||||
@@ -226,15 +114,7 @@ async def list_datasources(
|
||||
}
|
||||
)
|
||||
|
||||
if module:
|
||||
collector_list = [c for c in collector_list if c["module"] == module]
|
||||
if priority:
|
||||
collector_list = [c for c in collector_list if c["priority"] == priority]
|
||||
|
||||
return {
|
||||
"total": len(collector_list),
|
||||
"data": collector_list,
|
||||
}
|
||||
return {"total": len(collector_list), "data": collector_list}
|
||||
|
||||
|
||||
@router.get("/{source_id}")
|
||||
@@ -243,19 +123,20 @@ async def get_datasource(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
collector_name = get_collector_name(source_id)
|
||||
if not collector_name:
|
||||
datasource = await get_datasource_record(db, source_id)
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
info = COLLECTOR_INFO[collector_name]
|
||||
return {
|
||||
"id": info["id"],
|
||||
"name": info["name"],
|
||||
"module": info["module"],
|
||||
"priority": info["priority"],
|
||||
"frequency": f"{info['frequency_hours']}h",
|
||||
"collector_class": collector_name,
|
||||
"is_active": collector_registry.is_active(collector_name),
|
||||
"id": datasource.id,
|
||||
"name": datasource.name,
|
||||
"module": datasource.module,
|
||||
"priority": datasource.priority,
|
||||
"frequency": format_frequency_label(datasource.frequency_minutes),
|
||||
"frequency_minutes": datasource.frequency_minutes,
|
||||
"collector_class": datasource.collector_class,
|
||||
"source": datasource.source,
|
||||
"is_active": datasource.is_active,
|
||||
}
|
||||
|
||||
|
||||
@@ -263,24 +144,32 @@ async def get_datasource(
|
||||
async def enable_datasource(
|
||||
source_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
collector_name = get_collector_name(source_id)
|
||||
if not collector_name:
|
||||
datasource = await get_datasource_record(db, source_id)
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
collector_registry.set_active(collector_name, True)
|
||||
return {"status": "enabled", "source_id": source_id}
|
||||
|
||||
datasource.is_active = True
|
||||
await db.commit()
|
||||
await sync_datasource_job(datasource.id)
|
||||
return {"status": "enabled", "source_id": datasource.id}
|
||||
|
||||
|
||||
@router.post("/{source_id}/disable")
|
||||
async def disable_datasource(
|
||||
source_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
collector_name = get_collector_name(source_id)
|
||||
if not collector_name:
|
||||
datasource = await get_datasource_record(db, source_id)
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
collector_registry.set_active(collector_name, False)
|
||||
return {"status": "disabled", "source_id": source_id}
|
||||
|
||||
datasource.is_active = False
|
||||
await db.commit()
|
||||
await sync_datasource_job(datasource.id)
|
||||
return {"status": "disabled", "source_id": datasource.id}
|
||||
|
||||
|
||||
@router.get("/{source_id}/stats")
|
||||
@@ -289,26 +178,19 @@ async def get_datasource_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
collector_name = get_collector_name(source_id)
|
||||
if not collector_name:
|
||||
datasource = await get_datasource_record(db, source_id)
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
info = COLLECTOR_INFO[collector_name]
|
||||
source_name = info["name"]
|
||||
|
||||
query = select(func.count(CollectedData.id)).where(CollectedData.source == collector_name)
|
||||
result = await db.execute(query)
|
||||
result = await db.execute(
|
||||
select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
|
||||
)
|
||||
total = result.scalar() or 0
|
||||
|
||||
if total == 0:
|
||||
query = select(func.count(CollectedData.id)).where(CollectedData.source == source_name)
|
||||
result = await db.execute(query)
|
||||
total = result.scalar() or 0
|
||||
|
||||
return {
|
||||
"source_id": source_id,
|
||||
"collector_name": collector_name,
|
||||
"name": info["name"],
|
||||
"source_id": datasource.id,
|
||||
"collector_name": datasource.collector_class,
|
||||
"name": datasource.name,
|
||||
"total_records": total,
|
||||
}
|
||||
|
||||
@@ -317,30 +199,25 @@ async def get_datasource_stats(
|
||||
async def trigger_datasource(
|
||||
source_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
collector_name = get_collector_name(source_id)
|
||||
if not collector_name:
|
||||
datasource = await get_datasource_record(db, source_id)
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
from app.services.scheduler import run_collector_now
|
||||
|
||||
if not collector_registry.is_active(collector_name):
|
||||
if not datasource.is_active:
|
||||
raise HTTPException(status_code=400, detail="Data source is disabled")
|
||||
|
||||
success = run_collector_now(collector_name)
|
||||
success = run_collector_now(datasource.source)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'")
|
||||
|
||||
if success:
|
||||
return {
|
||||
"status": "triggered",
|
||||
"source_id": source_id,
|
||||
"collector_name": collector_name,
|
||||
"message": f"Collector '{collector_name}' has been triggered",
|
||||
}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to trigger collector '{collector_name}'",
|
||||
)
|
||||
return {
|
||||
"status": "triggered",
|
||||
"source_id": datasource.id,
|
||||
"collector_name": datasource.source,
|
||||
"message": f"Collector '{datasource.source}' has been triggered",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{source_id}/data")
|
||||
@@ -349,39 +226,25 @@ async def clear_datasource_data(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
collector_name = get_collector_name(source_id)
|
||||
if not collector_name:
|
||||
datasource = await get_datasource_record(db, source_id)
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
info = COLLECTOR_INFO[collector_name]
|
||||
source_name = info["name"]
|
||||
|
||||
query = select(func.count(CollectedData.id)).where(CollectedData.source == collector_name)
|
||||
result = await db.execute(query)
|
||||
result = await db.execute(
|
||||
select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source)
|
||||
)
|
||||
count = result.scalar() or 0
|
||||
|
||||
if count == 0:
|
||||
query = select(func.count(CollectedData.id)).where(CollectedData.source == source_name)
|
||||
result = await db.execute(query)
|
||||
count = result.scalar() or 0
|
||||
delete_source = source_name
|
||||
else:
|
||||
delete_source = collector_name
|
||||
return {"status": "success", "message": "No data to clear", "deleted_count": 0}
|
||||
|
||||
if count == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "No data to clear",
|
||||
"deleted_count": 0,
|
||||
}
|
||||
|
||||
delete_query = CollectedData.__table__.delete().where(CollectedData.source == delete_source)
|
||||
delete_query = CollectedData.__table__.delete().where(CollectedData.source == datasource.source)
|
||||
await db.execute(delete_query)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Cleared {count} records for data source '{info['name']}'",
|
||||
"message": f"Cleared {count} records for data source '{datasource.name}'",
|
||||
"deleted_count": count,
|
||||
}
|
||||
|
||||
@@ -391,22 +254,11 @@ async def get_task_status(
|
||||
source_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
collector_name = get_collector_name(source_id)
|
||||
if not collector_name:
|
||||
datasource = await get_datasource_record(db, source_id)
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
info = COLLECTOR_INFO[collector_name]
|
||||
|
||||
running_task_query = (
|
||||
select(CollectionTask)
|
||||
.where(CollectionTask.datasource_id == info["id"])
|
||||
.where(CollectionTask.status == "running")
|
||||
.order_by(CollectionTask.started_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
running_result = await db.execute(running_task_query)
|
||||
running_task = running_result.scalar_one_or_none()
|
||||
|
||||
running_task = await get_running_task(db, datasource.id)
|
||||
if not running_task:
|
||||
return {"is_running": False, "task_id": None, "progress": None}
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from app.models.user import User
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.datasource import DataSource
|
||||
from app.models.system_setting import SystemSetting
|
||||
from app.models.user import User
|
||||
from app.services.scheduler import sync_datasource_job
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
default_settings = {
|
||||
DEFAULT_SETTINGS = {
|
||||
"system": {
|
||||
"system_name": "智能星球",
|
||||
"refresh_interval": 60,
|
||||
@@ -29,17 +37,13 @@ default_settings = {
|
||||
},
|
||||
}
|
||||
|
||||
system_settings = default_settings["system"].copy()
|
||||
notification_settings = default_settings["notifications"].copy()
|
||||
security_settings = default_settings["security"].copy()
|
||||
|
||||
|
||||
class SystemSettingsUpdate(BaseModel):
|
||||
system_name: str = "智能星球"
|
||||
refresh_interval: int = 60
|
||||
refresh_interval: int = Field(default=60, ge=10, le=3600)
|
||||
auto_refresh: bool = True
|
||||
data_retention_days: int = 30
|
||||
max_concurrent_tasks: int = 5
|
||||
data_retention_days: int = Field(default=30, ge=1, le=3650)
|
||||
max_concurrent_tasks: int = Field(default=5, ge=1, le=50)
|
||||
|
||||
|
||||
class NotificationSettingsUpdate(BaseModel):
|
||||
@@ -51,60 +55,166 @@ class NotificationSettingsUpdate(BaseModel):
|
||||
|
||||
|
||||
class SecuritySettingsUpdate(BaseModel):
|
||||
session_timeout: int = 60
|
||||
max_login_attempts: int = 5
|
||||
password_policy: str = "medium"
|
||||
session_timeout: int = Field(default=60, ge=5, le=1440)
|
||||
max_login_attempts: int = Field(default=5, ge=1, le=20)
|
||||
password_policy: str = Field(default="medium")
|
||||
|
||||
|
||||
class CollectorSettingsUpdate(BaseModel):
|
||||
is_active: bool
|
||||
priority: str = Field(default="P1")
|
||||
frequency_minutes: int = Field(default=60, ge=1, le=10080)
|
||||
|
||||
|
||||
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
|
||||
merged = DEFAULT_SETTINGS[category].copy()
|
||||
if payload:
|
||||
merged.update(payload)
|
||||
return merged
|
||||
|
||||
|
||||
async def get_setting_record(db: AsyncSession, category: str) -> Optional[SystemSetting]:
|
||||
result = await db.execute(select(SystemSetting).where(SystemSetting.category == category))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_setting_payload(db: AsyncSession, category: str) -> dict:
|
||||
record = await get_setting_record(db, category)
|
||||
return merge_with_defaults(category, record.payload if record else None)
|
||||
|
||||
|
||||
async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -> dict:
|
||||
record = await get_setting_record(db, category)
|
||||
if record is None:
|
||||
record = SystemSetting(category=category, payload=payload)
|
||||
db.add(record)
|
||||
else:
|
||||
record.payload = payload
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return merge_with_defaults(category, record.payload)
|
||||
|
||||
|
||||
def format_frequency_label(minutes: int) -> str:
|
||||
if minutes % 1440 == 0:
|
||||
return f"{minutes // 1440}d"
|
||||
if minutes % 60 == 0:
|
||||
return f"{minutes // 60}h"
|
||||
return f"{minutes}m"
|
||||
|
||||
|
||||
def serialize_collector(datasource: DataSource) -> dict:
|
||||
return {
|
||||
"id": datasource.id,
|
||||
"name": datasource.name,
|
||||
"source": datasource.source,
|
||||
"module": datasource.module,
|
||||
"priority": datasource.priority,
|
||||
"frequency_minutes": datasource.frequency_minutes,
|
||||
"frequency": format_frequency_label(datasource.frequency_minutes),
|
||||
"is_active": datasource.is_active,
|
||||
"last_run_at": datasource.last_run_at.isoformat() if datasource.last_run_at else None,
|
||||
"last_status": datasource.last_status,
|
||||
"next_run_at": datasource.next_run_at.isoformat() if datasource.next_run_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/system")
|
||||
async def get_system_settings(current_user: User = Depends(get_current_user)):
|
||||
return {"system": system_settings}
|
||||
async def get_system_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return {"system": await get_setting_payload(db, "system")}
|
||||
|
||||
|
||||
@router.put("/system")
|
||||
async def update_system_settings(
|
||||
settings: SystemSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
global system_settings
|
||||
system_settings = settings.model_dump()
|
||||
return {"status": "updated", "system": system_settings}
|
||||
payload = await save_setting_payload(db, "system", settings.model_dump())
|
||||
return {"status": "updated", "system": payload}
|
||||
|
||||
|
||||
@router.get("/notifications")
|
||||
async def get_notification_settings(current_user: User = Depends(get_current_user)):
|
||||
return {"notifications": notification_settings}
|
||||
async def get_notification_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return {"notifications": await get_setting_payload(db, "notifications")}
|
||||
|
||||
|
||||
@router.put("/notifications")
|
||||
async def update_notification_settings(
|
||||
settings: NotificationSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
global notification_settings
|
||||
notification_settings = settings.model_dump()
|
||||
return {"status": "updated", "notifications": notification_settings}
|
||||
payload = await save_setting_payload(db, "notifications", settings.model_dump())
|
||||
return {"status": "updated", "notifications": payload}
|
||||
|
||||
|
||||
@router.get("/security")
|
||||
async def get_security_settings(current_user: User = Depends(get_current_user)):
|
||||
return {"security": security_settings}
|
||||
async def get_security_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return {"security": await get_setting_payload(db, "security")}
|
||||
|
||||
|
||||
@router.put("/security")
|
||||
async def update_security_settings(
|
||||
settings: SecuritySettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
global security_settings
|
||||
security_settings = settings.model_dump()
|
||||
return {"status": "updated", "security": security_settings}
|
||||
payload = await save_setting_payload(db, "security", settings.model_dump())
|
||||
return {"status": "updated", "security": payload}
|
||||
|
||||
|
||||
@router.get("/collectors")
|
||||
async def get_collector_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
|
||||
datasources = result.scalars().all()
|
||||
return {"collectors": [serialize_collector(datasource) for datasource in datasources]}
|
||||
|
||||
|
||||
@router.put("/collectors/{datasource_id}")
|
||||
async def update_collector_settings(
|
||||
datasource_id: int,
|
||||
settings: CollectorSettingsUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
datasource = await db.get(DataSource, datasource_id)
|
||||
if not datasource:
|
||||
raise HTTPException(status_code=404, detail="Data source not found")
|
||||
|
||||
datasource.is_active = settings.is_active
|
||||
datasource.priority = settings.priority
|
||||
datasource.frequency_minutes = settings.frequency_minutes
|
||||
await db.commit()
|
||||
await db.refresh(datasource)
|
||||
await sync_datasource_job(datasource.id)
|
||||
return {"status": "updated", "collector": serialize_collector(datasource)}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_all_settings(current_user: User = Depends(get_current_user)):
|
||||
async def get_all_settings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
|
||||
datasources = result.scalars().all()
|
||||
return {
|
||||
"system": system_settings,
|
||||
"notifications": notification_settings,
|
||||
"security": security_settings,
|
||||
"system": await get_setting_payload(db, "system"),
|
||||
"notifications": await get_setting_payload(db, "notifications"),
|
||||
"security": await get_setting_payload(db, "security"),
|
||||
"collectors": [serialize_collector(datasource) for datasource in datasources],
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
126
backend/app/core/datasource_defaults.py
Normal file
126
backend/app/core/datasource_defaults.py
Normal 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()}
|
||||
@@ -25,11 +25,52 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
raise
|
||||
|
||||
|
||||
async def seed_default_datasources(session: AsyncSession):
|
||||
from app.core.datasource_defaults import DEFAULT_DATASOURCES
|
||||
from app.models.datasource import DataSource
|
||||
|
||||
for source, info in DEFAULT_DATASOURCES.items():
|
||||
existing = await session.get(DataSource, info["id"])
|
||||
if existing:
|
||||
existing.name = info["name"]
|
||||
existing.source = source
|
||||
existing.module = info["module"]
|
||||
existing.priority = info["priority"]
|
||||
existing.frequency_minutes = info["frequency_minutes"]
|
||||
existing.collector_class = source
|
||||
if existing.config is None:
|
||||
existing.config = "{}"
|
||||
continue
|
||||
|
||||
session.add(
|
||||
DataSource(
|
||||
id=info["id"],
|
||||
name=info["name"],
|
||||
source=source,
|
||||
module=info["module"],
|
||||
priority=info["priority"],
|
||||
frequency_minutes=info["frequency_minutes"],
|
||||
collector_class=source,
|
||||
config="{}",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def init_db():
|
||||
import app.models.user # noqa: F401
|
||||
import app.models.gpu_cluster # noqa: F401
|
||||
import app.models.task # noqa: F401
|
||||
import app.models.datasource # noqa: F401
|
||||
import app.models.datasource_config # noqa: F401
|
||||
import app.models.alert # noqa: F401
|
||||
import app.models.collected_data # noqa: F401
|
||||
import app.models.system_setting # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
async with async_session_factory() as session:
|
||||
await seed_default_datasources(session)
|
||||
|
||||
@@ -2,15 +2,14 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.db.session import init_db, async_session_factory
|
||||
from app.api.main import api_router
|
||||
from app.api.v1 import websocket
|
||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||
from app.core.config import settings
|
||||
from app.core.websocket.broadcaster import broadcaster
|
||||
from app.db.session import init_db
|
||||
from app.services.scheduler import start_scheduler, stop_scheduler, sync_scheduler_with_datasources
|
||||
|
||||
|
||||
class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
||||
@@ -28,6 +27,7 @@ class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
start_scheduler()
|
||||
await sync_scheduler_with_datasources()
|
||||
broadcaster.start()
|
||||
yield
|
||||
broadcaster.stop()
|
||||
@@ -60,16 +60,11 @@ app.include_router(websocket.router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查端点"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": settings.VERSION,
|
||||
}
|
||||
return {"status": "healthy", "version": settings.VERSION}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""API根目录"""
|
||||
return {
|
||||
"name": settings.PROJECT_NAME,
|
||||
"version": settings.VERSION,
|
||||
@@ -80,7 +75,6 @@ async def root():
|
||||
|
||||
@app.get("/api/v1/scheduler/jobs")
|
||||
async def get_scheduler_jobs():
|
||||
"""获取调度任务列表"""
|
||||
from app.services.scheduler import get_scheduler_jobs
|
||||
|
||||
return {"jobs": get_scheduler_jobs()}
|
||||
|
||||
@@ -2,13 +2,17 @@ from app.models.user import User
|
||||
from app.models.gpu_cluster import GPUCluster
|
||||
from app.models.task import CollectionTask
|
||||
from app.models.datasource import DataSource
|
||||
from app.models.datasource_config import DataSourceConfig
|
||||
from app.models.alert import Alert, AlertSeverity, AlertStatus
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"GPUCluster",
|
||||
"CollectionTask",
|
||||
"DataSource",
|
||||
"DataSourceConfig",
|
||||
"SystemSetting",
|
||||
"Alert",
|
||||
"AlertSeverity",
|
||||
"AlertStatus",
|
||||
|
||||
19
backend/app/models/system_setting.py
Normal file
19
backend/app/models/system_setting.py
Normal 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}>"
|
||||
@@ -1,15 +1,16 @@
|
||||
"""Task Scheduler for running collection jobs"""
|
||||
"""Task Scheduler for running collection jobs."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
from typing import Any, Dict
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import async_session_factory
|
||||
from app.models.datasource import DataSource
|
||||
from app.services.collectors.registry import collector_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,77 +18,119 @@ logger = logging.getLogger(__name__)
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
COLLECTOR_TO_ID = {
|
||||
"top500": 1,
|
||||
"epoch_ai_gpu": 2,
|
||||
"huggingface_models": 3,
|
||||
"huggingface_datasets": 4,
|
||||
"huggingface_spaces": 5,
|
||||
"peeringdb_ixp": 6,
|
||||
"peeringdb_network": 7,
|
||||
"peeringdb_facility": 8,
|
||||
"telegeography_cables": 9,
|
||||
"telegeography_landing": 10,
|
||||
"telegeography_systems": 11,
|
||||
"arcgis_cables": 15,
|
||||
"arcgis_landing_points": 16,
|
||||
"arcgis_cable_landing_relation": 17,
|
||||
"fao_landing_points": 18,
|
||||
"spacetrack_tle": 19,
|
||||
"celestrak_tle": 20,
|
||||
}
|
||||
async def _update_next_run_at(datasource: DataSource, session) -> None:
|
||||
job = scheduler.get_job(datasource.source)
|
||||
datasource.next_run_at = job.next_run_time if job else None
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _apply_datasource_schedule(datasource: DataSource, session) -> None:
|
||||
collector = collector_registry.get(datasource.source)
|
||||
if not collector:
|
||||
logger.warning("Collector not found for datasource %s", datasource.source)
|
||||
return
|
||||
|
||||
collector_registry.set_active(datasource.source, datasource.is_active)
|
||||
|
||||
existing_job = scheduler.get_job(datasource.source)
|
||||
if existing_job:
|
||||
scheduler.remove_job(datasource.source)
|
||||
|
||||
if datasource.is_active:
|
||||
scheduler.add_job(
|
||||
run_collector_task,
|
||||
trigger=IntervalTrigger(minutes=max(1, datasource.frequency_minutes)),
|
||||
id=datasource.source,
|
||||
name=datasource.name,
|
||||
replace_existing=True,
|
||||
kwargs={"collector_name": datasource.source},
|
||||
)
|
||||
logger.info(
|
||||
"Scheduled collector: %s (every %sm)",
|
||||
datasource.source,
|
||||
datasource.frequency_minutes,
|
||||
)
|
||||
else:
|
||||
logger.info("Collector disabled: %s", datasource.source)
|
||||
|
||||
await _update_next_run_at(datasource, session)
|
||||
|
||||
|
||||
async def run_collector_task(collector_name: str):
|
||||
"""Run a single collector task"""
|
||||
"""Run a single collector task."""
|
||||
collector = collector_registry.get(collector_name)
|
||||
if not collector:
|
||||
logger.error(f"Collector not found: {collector_name}")
|
||||
logger.error("Collector not found: %s", collector_name)
|
||||
return
|
||||
|
||||
# Get the correct datasource_id
|
||||
datasource_id = COLLECTOR_TO_ID.get(collector_name, 1)
|
||||
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(select(DataSource).where(DataSource.source == collector_name))
|
||||
datasource = result.scalar_one_or_none()
|
||||
if not datasource:
|
||||
logger.error("Datasource not found for collector: %s", collector_name)
|
||||
return
|
||||
|
||||
if not datasource.is_active:
|
||||
logger.info("Skipping disabled collector: %s", collector_name)
|
||||
return
|
||||
|
||||
try:
|
||||
# Set the datasource_id on the collector instance
|
||||
collector._datasource_id = datasource_id
|
||||
|
||||
logger.info(f"Running collector: {collector_name} (datasource_id={datasource_id})")
|
||||
result = await collector.run(db)
|
||||
logger.info(f"Collector {collector_name} completed: {result}")
|
||||
except Exception as e:
|
||||
logger.error(f"Collector {collector_name} failed: {e}")
|
||||
collector._datasource_id = datasource.id
|
||||
logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id)
|
||||
task_result = await collector.run(db)
|
||||
datasource.last_run_at = datetime.utcnow()
|
||||
datasource.last_status = task_result.get("status")
|
||||
await _update_next_run_at(datasource, db)
|
||||
logger.info("Collector %s completed: %s", collector_name, task_result)
|
||||
except Exception as exc:
|
||||
datasource.last_run_at = datetime.utcnow()
|
||||
datasource.last_status = "failed"
|
||||
await db.commit()
|
||||
logger.exception("Collector %s failed: %s", collector_name, exc)
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""Start the scheduler with all registered collectors"""
|
||||
collectors = collector_registry.all()
|
||||
|
||||
for name, collector in collectors.items():
|
||||
if collector_registry.is_active(name):
|
||||
scheduler.add_job(
|
||||
run_collector_task,
|
||||
trigger=IntervalTrigger(hours=collector.frequency_hours),
|
||||
id=name,
|
||||
name=name,
|
||||
replace_existing=True,
|
||||
kwargs={"collector_name": name},
|
||||
)
|
||||
logger.info(f"Scheduled collector: {name} (every {collector.frequency_hours}h)")
|
||||
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
def start_scheduler() -> None:
|
||||
"""Start the scheduler."""
|
||||
if not scheduler.running:
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""Stop the scheduler"""
|
||||
scheduler.shutdown()
|
||||
logger.info("Scheduler stopped")
|
||||
def stop_scheduler() -> None:
|
||||
"""Stop the scheduler."""
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
logger.info("Scheduler stopped")
|
||||
|
||||
|
||||
async def sync_scheduler_with_datasources() -> None:
|
||||
"""Synchronize scheduler jobs with datasource table."""
|
||||
async with async_session_factory() as db:
|
||||
result = await db.execute(select(DataSource).order_by(DataSource.id))
|
||||
datasources = result.scalars().all()
|
||||
|
||||
configured_sources = {datasource.source for datasource in datasources}
|
||||
for job in list(scheduler.get_jobs()):
|
||||
if job.id not in configured_sources:
|
||||
scheduler.remove_job(job.id)
|
||||
|
||||
for datasource in datasources:
|
||||
await _apply_datasource_schedule(datasource, db)
|
||||
|
||||
|
||||
async def sync_datasource_job(datasource_id: int) -> bool:
|
||||
"""Synchronize a single datasource job after settings changes."""
|
||||
async with async_session_factory() as db:
|
||||
datasource = await db.get(DataSource, datasource_id)
|
||||
if not datasource:
|
||||
return False
|
||||
|
||||
await _apply_datasource_schedule(datasource, db)
|
||||
return True
|
||||
|
||||
|
||||
def get_scheduler_jobs() -> list[Dict[str, Any]]:
|
||||
"""Get all scheduled jobs"""
|
||||
"""Get all scheduled jobs."""
|
||||
jobs = []
|
||||
for job in scheduler.get_jobs():
|
||||
jobs.append(
|
||||
@@ -101,52 +144,17 @@ def get_scheduler_jobs() -> list[Dict[str, Any]]:
|
||||
return jobs
|
||||
|
||||
|
||||
def add_job(collector_name: str, hours: int = 4):
|
||||
"""Add a new scheduled job"""
|
||||
collector = collector_registry.get(collector_name)
|
||||
if not collector:
|
||||
raise ValueError(f"Collector not found: {collector_name}")
|
||||
|
||||
scheduler.add_job(
|
||||
run_collector_task,
|
||||
trigger=IntervalTrigger(hours=hours),
|
||||
id=collector_name,
|
||||
name=collector_name,
|
||||
replace_existing=True,
|
||||
kwargs={"collector_name": collector_name},
|
||||
)
|
||||
logger.info(f"Added scheduled job: {collector_name} (every {hours}h)")
|
||||
|
||||
|
||||
def remove_job(collector_name: str):
|
||||
"""Remove a scheduled job"""
|
||||
scheduler.remove_job(collector_name)
|
||||
logger.info(f"Removed scheduled job: {collector_name}")
|
||||
|
||||
|
||||
def pause_job(collector_name: str):
|
||||
"""Pause a scheduled job"""
|
||||
scheduler.pause_job(collector_name)
|
||||
logger.info(f"Paused job: {collector_name}")
|
||||
|
||||
|
||||
def resume_job(collector_name: str):
|
||||
"""Resume a scheduled job"""
|
||||
scheduler.resume_job(collector_name)
|
||||
logger.info(f"Resumed job: {collector_name}")
|
||||
|
||||
|
||||
def run_collector_now(collector_name: str) -> bool:
|
||||
"""Run a collector immediately (not scheduled)"""
|
||||
"""Run a collector immediately (not scheduled)."""
|
||||
collector = collector_registry.get(collector_name)
|
||||
if not collector:
|
||||
logger.error(f"Collector not found: {collector_name}")
|
||||
logger.error("Collector not found: %s", collector_name)
|
||||
return False
|
||||
|
||||
try:
|
||||
asyncio.create_task(run_collector_task(collector_name))
|
||||
logger.info(f"Triggered collector: {collector_name}")
|
||||
logger.info("Triggered collector: %s", collector_name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to trigger collector {collector_name}: {e}")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
|
||||
return False
|
||||
|
||||
47
docs/system-settings-plan.md
Normal file
47
docs/system-settings-plan.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 系统配置中心开发计划
|
||||
|
||||
## 目标
|
||||
|
||||
将当前仅保存于内存中的“系统配置”页面升级为真正可用的配置中心,优先服务以下两类能力:
|
||||
|
||||
1. 系统级配置持久化
|
||||
2. 采集调度配置管理
|
||||
|
||||
## 第一阶段范围
|
||||
|
||||
### 1. 系统配置持久化
|
||||
|
||||
- 新增 `system_settings` 表,用于保存分类配置
|
||||
- 将系统、通知、安全配置从进程内存迁移到数据库
|
||||
- 提供统一读取接口,页面刷新和服务重启后保持不丢失
|
||||
|
||||
### 2. 采集调度配置接入真实数据源
|
||||
|
||||
- 统一内置采集器默认定义
|
||||
- 启动时自动初始化 `data_sources` 表
|
||||
- 配置页允许修改:
|
||||
- 是否启用
|
||||
- 采集频率(分钟)
|
||||
- 优先级
|
||||
- 修改后实时同步到调度器
|
||||
|
||||
### 3. 前端配置页重构
|
||||
|
||||
- 将当前通用模板页调整为项目专用配置中心
|
||||
- 增加“采集调度”Tab
|
||||
- 保留“系统显示 / 通知 / 安全”三类配置
|
||||
- 将设置页正式接入主路由
|
||||
|
||||
## 非本阶段内容
|
||||
|
||||
- 邮件发送能力本身
|
||||
- 配置审计历史
|
||||
- 敏感凭证加密管理
|
||||
- 多租户或按角色细粒度配置
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 设置项修改后重启服务仍然存在
|
||||
- 配置页可以查看并修改所有内置采集器的启停与采集频率
|
||||
- 调整采集频率后,调度器任务随之更新
|
||||
- `/settings` 页面可从主导航进入并正常工作
|
||||
@@ -6,6 +6,7 @@ import Users from './pages/Users/Users'
|
||||
import DataSources from './pages/DataSources/DataSources'
|
||||
import DataList from './pages/DataList/DataList'
|
||||
import Earth from './pages/Earth/Earth'
|
||||
import Settings from './pages/Settings/Settings'
|
||||
|
||||
function App() {
|
||||
const { token } = useAuthStore()
|
||||
@@ -23,6 +24,7 @@ function App() {
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/datasources" element={<DataSources />} />
|
||||
<Route path="/data" element={<DataList />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { Layout, Menu, Typography, Button } from 'antd'
|
||||
import { Layout, Menu, Typography, Button, Space } from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
const { Sider, Content } = Layout
|
||||
const { Text } = Typography
|
||||
|
||||
interface AppLayoutProps {
|
||||
@@ -23,6 +23,7 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
const location = useLocation()
|
||||
const { user, logout } = useAuthStore()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const showBanner = true
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||
@@ -35,42 +36,55 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
return (
|
||||
<Layout className="dashboard-layout">
|
||||
<Sider
|
||||
width={240}
|
||||
collapsedWidth={80}
|
||||
width={208}
|
||||
collapsedWidth={72}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
className="dashboard-sider"
|
||||
>
|
||||
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{collapsed ? (
|
||||
<Text strong style={{ color: 'white', fontSize: 20 }}>🌏</Text>
|
||||
) : (
|
||||
<Text strong style={{ color: 'white', fontSize: 18 }}>智能星球</Text>
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
<Text strong>欢迎, {user?.username}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button type="link" danger onClick={logout}>退出登录</Button>
|
||||
<div className="dashboard-sider-inner">
|
||||
<div>
|
||||
<div className={`dashboard-brand ${collapsed ? 'dashboard-brand--collapsed' : ''}`} onClick={collapsed ? () => setCollapsed(false) : undefined}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setCollapsed(!collapsed)
|
||||
}}
|
||||
className={`dashboard-sider-toggle ${collapsed ? 'dashboard-sider-toggle--collapsed' : ''}`}
|
||||
/>
|
||||
{!collapsed ? (
|
||||
<Text strong style={{ color: 'white', fontSize: 18 }}>智能星球</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
/>
|
||||
</div>
|
||||
</Header>
|
||||
<Content className="dashboard-content" style={{ padding: 24, minHeight: '100%', overflow: 'auto' }}>
|
||||
{children}
|
||||
|
||||
{showBanner && !collapsed ? (
|
||||
<div className="dashboard-sider-banner">
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text className="dashboard-sider-banner-label">当前账号</Text>
|
||||
<Text strong className="dashboard-sider-banner-value">{user?.username}</Text>
|
||||
</div>
|
||||
<Button type="primary" danger ghost block onClick={logout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Sider>
|
||||
<Layout style={{ minWidth: 0, minHeight: 0, height: '100%' }}>
|
||||
<Content className="dashboard-content" style={{ padding: 24, minHeight: 0, height: '100%' }}>
|
||||
<div className="dashboard-content-inner">{children}</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
@@ -31,29 +31,247 @@ body {
|
||||
}
|
||||
|
||||
.dashboard-layout {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.dashboard-layout .ant-layout,
|
||||
.dashboard-layout .ant-layout-content {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dashboard-layout > .ant-layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-sider {
|
||||
background: #001529 !important;
|
||||
}
|
||||
|
||||
.ant-layout-sider-trigger {
|
||||
display: none !important;
|
||||
.dashboard-sider-inner {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
background: white;
|
||||
padding: 0 24px;
|
||||
.dashboard-brand {
|
||||
position: relative;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
padding-left: 12px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.dashboard-brand .ant-typography {
|
||||
margin-right: auto;
|
||||
padding-left: 24px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.dashboard-brand--collapsed {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-sider-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateY(-50%);
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
}
|
||||
|
||||
.dashboard-sider-toggle--collapsed {
|
||||
left: 50%;
|
||||
right: auto;
|
||||
width: 32px;
|
||||
transform: translate(-50%, -50%);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-sider-banner {
|
||||
margin: 12px;
|
||||
padding: 14px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dashboard-sider-banner-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dashboard-sider-banner-value {
|
||||
display: block;
|
||||
color: white !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-sider-logout {
|
||||
width: 100%;
|
||||
color: #ff7875 !important;
|
||||
}
|
||||
|
||||
.ant-layout-sider-trigger {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 64px);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-content-inner {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-content-inner > * {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-shell__header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-shell__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-shell__body > * {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
.data-source-tabs-shell,
|
||||
.data-source-tabs,
|
||||
.data-source-tabs .ant-tabs-content-holder,
|
||||
.data-source-tabs .ant-tabs-content,
|
||||
.data-source-tabs .ant-tabs-tabpane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.data-source-tabs-shell {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-source-tabs {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-source-tabs .ant-tabs-nav {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.data-source-tabs .ant-tabs-content-holder {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-source-tabs .ant-tabs-content {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-source-tabs .ant-tabs-tabpane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-source-custom-tab {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.data-source-custom-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.data-source-table-region {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-wrapper,
|
||||
.data-source-table-region .ant-spin-nested-loading,
|
||||
.data-source-table-region .ant-spin-container,
|
||||
.data-source-table-region .ant-table,
|
||||
.data-source-table-region .ant-table-container,
|
||||
.data-source-table-region .ant-table-body {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-wrapper,
|
||||
.data-source-table-region .ant-spin-nested-loading,
|
||||
.data-source-table-region .ant-spin-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-source-empty-state {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
@@ -88,37 +306,6 @@ body {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Table column resize */
|
||||
.ant-table-wrapper .ant-table-thead > tr > th {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.resize-handle::before {
|
||||
content: '';
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
background: #d9d9d9;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.resize-handle:hover::before {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
/* Table cell fixed width */
|
||||
.ant-table-wrapper .ant-table-tbody > tr > td {
|
||||
max-width: 0;
|
||||
@@ -126,3 +313,405 @@ body {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ant-table-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-container,
|
||||
.ant-table-wrapper .ant-table-content,
|
||||
.ant-table-wrapper .ant-table-body {
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
.table-scroll-region {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-scroll-region .ant-table-wrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-scroll-region .ant-table {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.data-list-workspace {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-list-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.data-list-controls-shell {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-list-split-layout {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.95fr) 12px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.data-list-summary-card,
|
||||
.data-list-table-shell {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-list-summary-card--panel,
|
||||
.data-list-summary-card--panel .ant-card-body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.data-list-summary-card--panel .ant-card-body {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.data-list-summary-card .ant-card-head,
|
||||
.data-list-table-shell .ant-card-head {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.data-list-summary-card .ant-card-body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.data-list-right-column {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-list-summary-treemap {
|
||||
min-height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(56px, 1fr);
|
||||
grid-auto-flow: dense;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.data-list-treemap-tile {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
color: #0f172a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-list-treemap-tile--ocean {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%);
|
||||
}
|
||||
|
||||
.data-list-treemap-tile--sky {
|
||||
background: linear-gradient(135deg, #e0f2fe 0%, #7dd3fc 100%);
|
||||
}
|
||||
|
||||
.data-list-treemap-tile--mint {
|
||||
background: linear-gradient(135deg, #dcfce7 0%, #86efac 100%);
|
||||
}
|
||||
|
||||
.data-list-treemap-tile--amber {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fcd34d 100%);
|
||||
}
|
||||
|
||||
.data-list-treemap-tile--rose {
|
||||
background: linear-gradient(135deg, #ffe4e6 0%, #fda4af 100%);
|
||||
}
|
||||
|
||||
.data-list-treemap-tile--violet {
|
||||
background: linear-gradient(135deg, #ede9fe 0%, #c4b5fd 100%);
|
||||
}
|
||||
|
||||
.data-list-treemap-tile--slate {
|
||||
background: linear-gradient(135deg, #e2e8f0 0%, #94a3b8 100%);
|
||||
}
|
||||
|
||||
.data-list-treemap-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-list-treemap-label {
|
||||
min-width: 0;
|
||||
font-size: clamp(11px, 0.75vw, 13px);
|
||||
line-height: 1.2;
|
||||
color: rgba(15, 23, 42, 0.78);
|
||||
}
|
||||
|
||||
.data-list-treemap-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.data-list-summary-tile-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
color: #0f172a;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.data-list-summary-tile-value {
|
||||
font-size: clamp(12px, 1vw, 16px);
|
||||
line-height: 1.1;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.data-list-treemap-meta {
|
||||
color: rgba(15, 23, 42, 0.72) !important;
|
||||
}
|
||||
|
||||
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.8);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.data-list-filter-grid {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.data-list-filter-grid--balanced > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.data-list-filter-grid--header {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.data-list-table-shell {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-list-table-shell .ant-card-body {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-list-table-header {
|
||||
padding: 12px 14px 0 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.data-list-table-header--with-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.data-list-table-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.data-list-table-region {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.data-list-table-region .ant-table-wrapper,
|
||||
.data-list-table-region .ant-spin-nested-loading,
|
||||
.data-list-table-region .ant-spin-container {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.data-list-table-region .ant-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-list-table-region .ant-spin-nested-loading,
|
||||
.data-list-table-region .ant-spin-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-list-table-region .ant-table {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.data-list-table-region .ant-table-pagination {
|
||||
flex: 0 0 auto;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.data-list-resize-handle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.data-list-resize-handle::before {
|
||||
content: '';
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: #d0d7e2;
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.data-list-resize-handle:hover::before {
|
||||
background: #8fb4ff;
|
||||
}
|
||||
|
||||
.data-list-resize-handle--vertical {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.data-list-resize-handle--vertical::before {
|
||||
width: 4px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.data-list-resize-handle--horizontal {
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.data-list-resize-handle--horizontal::before {
|
||||
width: 56px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
@media (min-width: 1201px) and (orientation: landscape) {
|
||||
.data-list-summary-treemap {
|
||||
grid-auto-rows: minmax(48px, 1fr);
|
||||
}
|
||||
|
||||
.data-list-treemap-tile {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.data-list-summary-tile-value {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.data-list-summary-treemap {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.data-list-split-layout {
|
||||
grid-template-columns: minmax(240px, 0.9fr) 12px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.dashboard-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.data-list-workspace {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.data-list-topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.data-list-split-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.data-list-right-column {
|
||||
grid-template-rows: auto auto;
|
||||
gap: 10px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.data-list-summary-treemap {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(88px, 1fr);
|
||||
}
|
||||
|
||||
.data-list-filter-grid {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-list-filter-grid--balanced > * {
|
||||
flex: 1 1 180px;
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.data-list-summary-treemap {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.data-list-filter-grid {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-list-filter-grid--balanced > * {
|
||||
flex-basis: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -174,7 +174,9 @@ function Alerts() {
|
||||
title="告警列表"
|
||||
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}>刷新</Button>}
|
||||
>
|
||||
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
|
||||
<div className="table-scroll-region">
|
||||
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Layout, Menu, Card, Row, Col, Statistic, Typography, Button, Tag, Spin } from 'antd'
|
||||
import { Card, Row, Col, Statistic, Typography, Button, Tag, Spin, Space } from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
BarChartOutlined,
|
||||
AlertOutlined,
|
||||
WifiOutlined,
|
||||
DisconnectOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface Stats {
|
||||
@@ -31,7 +27,7 @@ interface Stats {
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { user, logout, token, clearAuth } = useAuthStore()
|
||||
const { token, clearAuth } = useAuthStore()
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [wsConnected, setWsConnected] = useState(false)
|
||||
@@ -63,7 +59,7 @@ function Dashboard() {
|
||||
}
|
||||
|
||||
fetchStats()
|
||||
}, [token])
|
||||
}, [token, clearAuth])
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
@@ -112,28 +108,10 @@ function Dashboard() {
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
const handleClearAuth = () => {
|
||||
clearAuth()
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources">数据源</Link> },
|
||||
{ key: '/data', icon: <BarChartOutlined />, label: <Link to="/data">采集数据</Link> },
|
||||
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users">用户管理</Link> },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||
]
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
@@ -143,81 +121,78 @@ function Dashboard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="dashboard-layout">
|
||||
<Sider width={240} className="dashboard-sider">
|
||||
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title level={4} style={{ color: 'white', margin: 0 }}>智能星球</Title>
|
||||
</div>
|
||||
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/']} items={menuItems} />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text strong>欢迎, {user?.username}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<AppLayout>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>仪表盘</Title>
|
||||
<Text type="secondary">系统总览与实时态势</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
{wsConnected ? (
|
||||
<Tag icon={<WifiOutlined />} color="success">实时连接</Tag>
|
||||
) : (
|
||||
<Tag icon={<DisconnectOutlined />} color="default">离线</Tag>
|
||||
)}
|
||||
<Button type="link" danger onClick={handleLogout}>退出登录</Button>
|
||||
<Button type="link" onClick={handleClearAuth}>清除认证</Button>
|
||||
<Button type="link" icon={<ReloadOutlined />} onClick={handleRetry}>刷新</Button>
|
||||
</div>
|
||||
</Header>
|
||||
<Content className="dashboard-content">
|
||||
{error && (
|
||||
<Card style={{ marginBottom: 16, borderColor: '#ff4d4f' }}>
|
||||
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
|
||||
<Button type="default" icon={<ReloadOutlined />} onClick={handleRetry}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card style={{ borderColor: '#ff4d4f' }}>
|
||||
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card>
|
||||
<Statistic title="数据源总数" value={stats?.total_datasources || 0} prefix={<DatabaseOutlined />} />
|
||||
</Card>
|
||||
)}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="数据源总数" value={stats?.total_datasources || 0} prefix={<DatabaseOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="活跃数据源" value={stats?.active_datasources || 0} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="今日任务" value={stats?.tasks_today || 0} prefix={<BarChartOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="成功率" value={stats?.success_rate || 0} suffix="%" valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="警告" value={stats?.alerts?.warning || 0} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card>
|
||||
<Statistic title="提示" value={stats?.alerts?.info || 0} valueStyle={{ color: '#1890ff' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{stats?.last_updated && (
|
||||
<div style={{ marginTop: 16, textAlign: 'center', color: '#8c8c8c' }}>
|
||||
最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')}
|
||||
{wsConnected && <Tag color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card>
|
||||
<Statistic title="活跃数据源" value={stats?.active_datasources || 0} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card>
|
||||
<Statistic title="今日任务" value={stats?.tasks_today || 0} prefix={<BarChartOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card>
|
||||
<Statistic title="成功率" value={stats?.success_rate || 0} suffix="%" valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="警告" value={stats?.alerts?.warning || 0} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="提示" value={stats?.alerts?.info || 0} valueStyle={{ color: '#1890ff' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{stats?.last_updated && (
|
||||
<div style={{ textAlign: 'center', color: '#8c8c8c' }}>
|
||||
最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')}
|
||||
{wsConnected && <Tag color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Table, Tag, Space, Card, Row, Col, Select, Input, Button,
|
||||
Statistic, Modal, Descriptions, Spin, Empty, Tooltip
|
||||
Table, Tag, Space, Card, Select, Input, Button,
|
||||
Modal, Descriptions, Spin, Empty, Tooltip, Typography, Grid
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
|
||||
AppstoreOutlined, EyeOutlined, SearchOutlined
|
||||
AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
const { useBreakpoint } = Grid
|
||||
|
||||
interface CollectedData {
|
||||
id: number
|
||||
source: string
|
||||
@@ -38,6 +41,21 @@ interface Summary {
|
||||
}
|
||||
|
||||
function DataList() {
|
||||
const screens = useBreakpoint()
|
||||
const isCompact = !screens.lg
|
||||
const topbarRef = useRef<HTMLDivElement | null>(null)
|
||||
const workspaceRef = useRef<HTMLDivElement | null>(null)
|
||||
const mainAreaRef = useRef<HTMLDivElement | null>(null)
|
||||
const rightColumnRef = useRef<HTMLDivElement | null>(null)
|
||||
const tableHeaderRef = useRef<HTMLDivElement | null>(null)
|
||||
const hasCustomLeftWidthRef = useRef(false)
|
||||
|
||||
const [mainAreaWidth, setMainAreaWidth] = useState(0)
|
||||
const [mainAreaHeight, setMainAreaHeight] = useState(0)
|
||||
const [rightColumnHeight, setRightColumnHeight] = useState(0)
|
||||
const [tableHeaderHeight, setTableHeaderHeight] = useState(0)
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(360)
|
||||
|
||||
const [data, setData] = useState<CollectedData[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [summary, setSummary] = useState<Summary | null>(null)
|
||||
@@ -55,6 +73,73 @@ function DataList() {
|
||||
const [detailData, setDetailData] = useState<CollectedData | null>(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const updateLayout = () => {
|
||||
setMainAreaWidth(mainAreaRef.current?.offsetWidth || 0)
|
||||
setMainAreaHeight(mainAreaRef.current?.offsetHeight || 0)
|
||||
setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0)
|
||||
setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0)
|
||||
}
|
||||
|
||||
updateLayout()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateLayout)
|
||||
if (workspaceRef.current) observer.observe(workspaceRef.current)
|
||||
if (topbarRef.current) observer.observe(topbarRef.current)
|
||||
if (mainAreaRef.current) observer.observe(mainAreaRef.current)
|
||||
if (rightColumnRef.current) observer.observe(rightColumnRef.current)
|
||||
if (tableHeaderRef.current) observer.observe(tableHeaderRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [isCompact])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompact || mainAreaWidth === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const minLeft = 260
|
||||
const minRight = 360
|
||||
const maxLeft = Math.max(minLeft, mainAreaWidth - minRight - 12)
|
||||
const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 4), maxLeft))
|
||||
|
||||
setLeftPanelWidth((current) => {
|
||||
if (!hasCustomLeftWidthRef.current) {
|
||||
return preferredLeft
|
||||
}
|
||||
return Math.max(minLeft, Math.min(current, maxLeft))
|
||||
})
|
||||
}, [isCompact, mainAreaWidth])
|
||||
|
||||
const beginHorizontalResize = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isCompact) return
|
||||
event.preventDefault()
|
||||
hasCustomLeftWidthRef.current = true
|
||||
const startX = event.clientX
|
||||
const startWidth = leftPanelWidth
|
||||
const containerWidth = mainAreaRef.current?.offsetWidth || 0
|
||||
|
||||
const onMove = (moveEvent: MouseEvent) => {
|
||||
const minLeft = 260
|
||||
const minRight = 360
|
||||
const maxLeft = Math.max(minLeft, containerWidth - minRight - 12)
|
||||
const nextWidth = startWidth + moveEvent.clientX - startX
|
||||
setLeftPanelWidth(Math.max(minLeft, Math.min(nextWidth, maxLeft)))
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -115,6 +200,15 @@ function DataList() {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setSourceFilter(undefined)
|
||||
setTypeFilter(undefined)
|
||||
setCountryFilter(undefined)
|
||||
setSearchText('')
|
||||
setPage(1)
|
||||
setTimeout(fetchData, 0)
|
||||
}
|
||||
|
||||
const handleViewDetail = async (id: number) => {
|
||||
setDetailVisible(true)
|
||||
setDetailLoading(true)
|
||||
@@ -130,102 +224,115 @@ function DataList() {
|
||||
|
||||
const getSourceIcon = (source: string) => {
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
'top500': <CloudServerOutlined />,
|
||||
'huggingface_models': <AppstoreOutlined />,
|
||||
'huggingface_datasets': <DatabaseOutlined />,
|
||||
'huggingface_spaces': <AppstoreOutlined />,
|
||||
'telegeography_cables': <GlobalOutlined />,
|
||||
'epoch_ai_gpu': <CloudServerOutlined />,
|
||||
top500: <CloudServerOutlined />,
|
||||
huggingface_models: <AppstoreOutlined />,
|
||||
huggingface_datasets: <DatabaseOutlined />,
|
||||
huggingface_spaces: <AppstoreOutlined />,
|
||||
telegeography_cables: <GlobalOutlined />,
|
||||
epoch_ai_gpu: <CloudServerOutlined />,
|
||||
}
|
||||
return iconMap[source] || <DatabaseOutlined />
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'supercomputer': 'red',
|
||||
'model': 'blue',
|
||||
'dataset': 'green',
|
||||
'space': 'purple',
|
||||
'submarine_cable': 'cyan',
|
||||
'gpu_cluster': 'orange',
|
||||
'ixp': 'magenta',
|
||||
'network': 'gold',
|
||||
'facility': 'lime',
|
||||
supercomputer: 'red',
|
||||
model: 'blue',
|
||||
dataset: 'green',
|
||||
space: 'purple',
|
||||
submarine_cable: 'cyan',
|
||||
gpu_cluster: 'orange',
|
||||
ixp: 'magenta',
|
||||
network: 'gold',
|
||||
facility: 'lime',
|
||||
}
|
||||
return colors[type] || 'default'
|
||||
}
|
||||
|
||||
const [columnsWidth, setColumnsWidth] = useState<Record<string, number>>({
|
||||
id: 60,
|
||||
name: 300,
|
||||
source: 150,
|
||||
data_type: 100,
|
||||
country: 100,
|
||||
value: 100,
|
||||
collected_at: 160,
|
||||
action: 80,
|
||||
})
|
||||
const activeFilterCount = useMemo(
|
||||
() => [sourceFilter, typeFilter, countryFilter, searchText.trim()].filter(Boolean).length,
|
||||
[sourceFilter, typeFilter, countryFilter, searchText]
|
||||
)
|
||||
|
||||
const resizeRef = useRef<{ startX: number; startWidth: number; key: string } | null>(null)
|
||||
const summaryItems = useMemo(() => {
|
||||
const items = [
|
||||
{ key: 'total', label: '总记录', value: summary?.total_records || 0, icon: <DatabaseOutlined /> },
|
||||
{ key: 'result', label: '筛选结果', value: total, icon: <SearchOutlined /> },
|
||||
{ key: 'filters', label: '启用筛选', value: activeFilterCount, icon: <FilterOutlined /> },
|
||||
{ key: 'sources', label: '数据源数', value: sources.length, icon: <DatabaseOutlined /> },
|
||||
]
|
||||
|
||||
const handleResizeStart = (key: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
resizeRef.current = {
|
||||
startX: e.clientX,
|
||||
startWidth: columnsWidth[key],
|
||||
key,
|
||||
for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) {
|
||||
items.push({
|
||||
key: item.source,
|
||||
label: item.source,
|
||||
value: item.count,
|
||||
icon: getSourceIcon(item.source),
|
||||
})
|
||||
}
|
||||
document.addEventListener('mousemove', handleResizeMove)
|
||||
document.addEventListener('mouseup', handleResizeEnd)
|
||||
}
|
||||
|
||||
const handleResizeMove = (e: MouseEvent) => {
|
||||
if (!resizeRef.current) return
|
||||
const diff = e.clientX - resizeRef.current.startX
|
||||
const newWidth = Math.max(50, resizeRef.current.startWidth + diff)
|
||||
setColumnsWidth((prev) => ({
|
||||
...prev,
|
||||
[resizeRef.current!.key]: newWidth,
|
||||
}))
|
||||
}
|
||||
return items
|
||||
}, [summary, total, activeFilterCount, isCompact, sources.length])
|
||||
|
||||
const handleResizeEnd = () => {
|
||||
resizeRef.current = null
|
||||
document.removeEventListener('mousemove', handleResizeMove)
|
||||
document.removeEventListener('mouseup', handleResizeEnd)
|
||||
}
|
||||
const treemapColumns = useMemo(() => {
|
||||
if (isCompact) return 1
|
||||
if (leftPanelWidth < 360) return 2
|
||||
if (leftPanelWidth < 520) return 3
|
||||
return 4
|
||||
}, [isCompact, leftPanelWidth])
|
||||
|
||||
const treemapRowHeight = useMemo(() => {
|
||||
if (isCompact) return 88
|
||||
if (leftPanelWidth < 360) return 44
|
||||
if (leftPanelWidth < 520) return 48
|
||||
return 56
|
||||
}, [isCompact, leftPanelWidth])
|
||||
|
||||
const treemapItems = useMemo(() => {
|
||||
const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate']
|
||||
const maxValue = Math.max(...summaryItems.map((item) => item.value), 1)
|
||||
const allowTallTiles = !isCompact && leftPanelWidth >= 520
|
||||
|
||||
return summaryItems.map((item, index) => {
|
||||
const ratio = item.value / maxValue
|
||||
let colSpan = 1
|
||||
let rowSpan = 1
|
||||
|
||||
if (allowTallTiles && index === 0) {
|
||||
colSpan = Math.min(2, treemapColumns)
|
||||
rowSpan = 2
|
||||
} else if (allowTallTiles && ratio >= 0.7) {
|
||||
colSpan = Math.min(2, treemapColumns)
|
||||
rowSpan = 2
|
||||
} else if (allowTallTiles && ratio >= 0.35) {
|
||||
rowSpan = 2
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
colSpan,
|
||||
rowSpan,
|
||||
tone: palette[index % palette.length],
|
||||
}
|
||||
})
|
||||
}, [summaryItems, isCompact, leftPanelWidth, treemapColumns])
|
||||
|
||||
const pageHeight = '100%'
|
||||
const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132
|
||||
const compactTableHeight = mainAreaHeight - tableHeaderHeight - 156
|
||||
const tableHeight = Math.max(180, isCompact ? compactTableHeight : desktopTableHeight)
|
||||
|
||||
const splitLayoutStyle = isCompact
|
||||
? undefined
|
||||
: { gridTemplateColumns: `${leftPanelWidth}px 12px minmax(0, 1fr)` }
|
||||
|
||||
const columns: ColumnsType<CollectedData> = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||
{
|
||||
title: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||
<span>ID</span>
|
||||
<div
|
||||
className="resize-handle"
|
||||
onMouseDown={handleResizeStart('id')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: columnsWidth.id,
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||
<span>名称</span>
|
||||
<div
|
||||
className="resize-handle"
|
||||
onMouseDown={handleResizeStart('name')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: columnsWidth.name,
|
||||
width: 280,
|
||||
ellipsis: true,
|
||||
render: (name: string, record: CollectedData) => (
|
||||
<Tooltip title={name}>
|
||||
@@ -236,101 +343,40 @@ function DataList() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||
<span>数据源</span>
|
||||
<div
|
||||
className="resize-handle"
|
||||
onMouseDown={handleResizeStart('source')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
title: '数据源',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
width: columnsWidth.source,
|
||||
render: (source: string) => (
|
||||
<Tag icon={getSourceIcon(source)}>{source}</Tag>
|
||||
),
|
||||
width: 170,
|
||||
render: (source: string) => <Tag icon={getSourceIcon(source)}>{source}</Tag>,
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||
<span>类型</span>
|
||||
<div
|
||||
className="resize-handle"
|
||||
onMouseDown={handleResizeStart('data_type')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
title: '类型',
|
||||
dataIndex: 'data_type',
|
||||
key: 'data_type',
|
||||
width: columnsWidth.data_type,
|
||||
render: (type: string) => (
|
||||
<Tag color={getTypeColor(type)}>{type}</Tag>
|
||||
),
|
||||
width: 120,
|
||||
render: (type: string) => <Tag color={getTypeColor(type)}>{type}</Tag>,
|
||||
},
|
||||
{ title: '国家/地区', dataIndex: 'country', key: 'country', width: 130, ellipsis: true },
|
||||
{
|
||||
title: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||
<span>国家/地区</span>
|
||||
<div
|
||||
className="resize-handle"
|
||||
onMouseDown={handleResizeStart('country')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
dataIndex: 'country',
|
||||
key: 'country',
|
||||
width: columnsWidth.country,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||
<span>数值</span>
|
||||
<div
|
||||
className="resize-handle"
|
||||
onMouseDown={handleResizeStart('value')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
title: '数值',
|
||||
dataIndex: 'value',
|
||||
key: 'value',
|
||||
width: columnsWidth.value,
|
||||
render: (value: string | null, record: CollectedData) => (
|
||||
value ? `${value} ${record.unit || ''}` : '-'
|
||||
),
|
||||
width: 140,
|
||||
render: (value: string | null, record: CollectedData) => (value ? `${value} ${record.unit || ''}` : '-'),
|
||||
},
|
||||
{
|
||||
title: () => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||
<span>采集时间</span>
|
||||
<div
|
||||
className="resize-handle"
|
||||
onMouseDown={handleResizeStart('collected_at')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
title: '采集时间',
|
||||
dataIndex: 'collected_at',
|
||||
key: 'collected_at',
|
||||
width: columnsWidth.collected_at,
|
||||
width: 180,
|
||||
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: columnsWidth.action,
|
||||
width: 96,
|
||||
render: (_: unknown, record: CollectedData) => (
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewDetail(record.id)}
|
||||
>
|
||||
<Button type="link" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
@@ -339,93 +385,160 @@ function DataList() {
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<h2>采集数据管理</h2>
|
||||
<div ref={workspaceRef} className="data-list-workspace" style={{ height: pageHeight }}>
|
||||
<div ref={topbarRef} className="data-list-topbar">
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>采集数据</Title>
|
||||
</div>
|
||||
<Space size={8} wrap>
|
||||
<Tag color="blue" style={{ marginInlineEnd: 0 }}>
|
||||
结果 {total.toLocaleString()} 条
|
||||
</Tag>
|
||||
<Tag color="default" style={{ marginInlineEnd: 0 }}>
|
||||
筛选 {activeFilterCount} 项
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && (
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总记录数"
|
||||
value={summary.total_records}
|
||||
prefix={<DatabaseOutlined />}
|
||||
/>
|
||||
<div ref={mainAreaRef} className="data-list-controls-shell">
|
||||
<div className="data-list-split-layout" style={splitLayoutStyle}>
|
||||
<Card
|
||||
className="data-list-summary-card data-list-summary-card--panel"
|
||||
title="数据概览"
|
||||
size="small"
|
||||
bodyStyle={{ padding: isCompact ? 12 : 16 }}
|
||||
>
|
||||
<div
|
||||
className="data-list-summary-treemap"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${treemapColumns}, minmax(0, 1fr))`,
|
||||
gridAutoRows: `minmax(${treemapRowHeight}px, 1fr)`,
|
||||
}}
|
||||
>
|
||||
{treemapItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`data-list-treemap-tile data-list-treemap-tile--${item.tone}`}
|
||||
style={{
|
||||
gridColumn: `span ${item.colSpan}`,
|
||||
gridRow: `span ${item.rowSpan}`,
|
||||
}}
|
||||
>
|
||||
<div className="data-list-treemap-head">
|
||||
<span className="data-list-summary-tile-icon">{item.icon}</span>
|
||||
<Text className="data-list-treemap-label">{item.label}</Text>
|
||||
</div>
|
||||
<div className="data-list-treemap-body">
|
||||
<Text strong className="data-list-summary-tile-value">
|
||||
{item.value.toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
{summary.source_totals.slice(0, 4).map((item) => (
|
||||
<Col span={6} key={item.source}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={item.source}
|
||||
value={item.count}
|
||||
prefix={getSourceIcon(item.source)}
|
||||
/>
|
||||
|
||||
{!isCompact && (
|
||||
<div
|
||||
className="data-list-resize-handle data-list-resize-handle--vertical"
|
||||
onMouseDown={beginHorizontalResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整左右分栏宽度"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div ref={rightColumnRef} className="data-list-right-column">
|
||||
<Card className="data-list-table-shell" bodyStyle={{ padding: 0 }}>
|
||||
<div ref={tableHeaderRef} className="data-list-table-header data-list-table-header--with-filters">
|
||||
<div className="data-list-table-header-main">
|
||||
<Space size={8} wrap>
|
||||
<Text strong>数据列表</Text>
|
||||
<Text type="secondary">共 {total.toLocaleString()} 条结果</Text>
|
||||
</Space>
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" onClick={handleReset}>清空</Button>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={fetchData}>刷新</Button>
|
||||
<Button size="small" type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="data-list-filter-grid data-list-filter-grid--balanced data-list-filter-grid--header">
|
||||
<Select
|
||||
size="middle"
|
||||
placeholder="数据源"
|
||||
allowClear
|
||||
value={sourceFilter}
|
||||
onChange={(value) => {
|
||||
setSourceFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={sources.map((source) => ({ label: source, value: source }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Select
|
||||
size="middle"
|
||||
placeholder="数据类型"
|
||||
allowClear
|
||||
value={typeFilter}
|
||||
onChange={(value) => {
|
||||
setTypeFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={types.map((type) => ({ label: type, value: type }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Select
|
||||
size="middle"
|
||||
placeholder="国家"
|
||||
allowClear
|
||||
value={countryFilter}
|
||||
onChange={(value) => {
|
||||
setCountryFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={countries.map((country) => ({ label: country, value: country }))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Input
|
||||
size="middle"
|
||||
placeholder="搜索名称"
|
||||
value={searchText}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-scroll-region data-list-table-region" style={{ padding: isCompact ? 10 : 12 }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
virtual
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
size={isCompact ? 'small' : 'middle'}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextPageSize)
|
||||
},
|
||||
showSizeChanger: true,
|
||||
showTotal: (count) => `共 ${count} 条`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="数据源"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
value={sourceFilter}
|
||||
onChange={(v) => { setSourceFilter(v); setPage(1); }}
|
||||
options={sources.map(s => ({ label: s, value: s }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="数据类型"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={typeFilter}
|
||||
onChange={(v) => { setTypeFilter(v); setPage(1); }}
|
||||
options={types.map(t => ({ label: t, value: t }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="国家"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={countryFilter}
|
||||
onChange={(v) => { setCountryFilter(v); setPage(1); }}
|
||||
options={countries.map(c => ({ label: c, value: c }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索名称"
|
||||
style={{ width: 200 }}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Data Table */}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
title="数据详情"
|
||||
open={detailVisible}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Table, Tag, Space, message, Button, Form, Input, Select,
|
||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber
|
||||
@@ -67,6 +67,10 @@ function DataSources() {
|
||||
const [recordCount, setRecordCount] = useState<number>(0)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<any>(null)
|
||||
const builtinTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
|
||||
const [customTableHeight, setCustomTableHeight] = useState(360)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -91,6 +95,28 @@ function DataSources() {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeights = () => {
|
||||
const builtinRegionHeight = builtinTableRegionRef.current?.offsetHeight || 0
|
||||
const customRegionHeight = customTableRegionRef.current?.offsetHeight || 0
|
||||
|
||||
setBuiltinTableHeight(Math.max(220, builtinRegionHeight - 56))
|
||||
setCustomTableHeight(Math.max(220, customRegionHeight - 56))
|
||||
}
|
||||
|
||||
updateHeights()
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateHeights)
|
||||
if (builtinTableRegionRef.current) observer.observe(builtinTableRegionRef.current)
|
||||
if (customTableRegionRef.current) observer.observe(customTableRegionRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [activeTab, builtInSources.length, customSources.length])
|
||||
|
||||
useEffect(() => {
|
||||
const runningSources = builtInSources.filter(s => s.is_running)
|
||||
if (runningSources.length === 0) return
|
||||
@@ -440,16 +466,21 @@ function DataSources() {
|
||||
key: 'builtin',
|
||||
label: '内置数据源',
|
||||
children: (
|
||||
<Table
|
||||
columns={builtinColumns}
|
||||
dataSource={builtInSources}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 800, y: 'auto' }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
<div className="page-shell__body">
|
||||
<div ref={builtinTableRegionRef} className="table-scroll-region data-source-table-region">
|
||||
<Table
|
||||
columns={builtinColumns}
|
||||
dataSource={builtInSources}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 800, y: builtinTableHeight }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
virtual
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -460,35 +491,48 @@ function DataSources() {
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, textAlign: 'right' }}>
|
||||
<div className="page-shell__body data-source-custom-tab">
|
||||
<div className="data-source-custom-toolbar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
添加数据源
|
||||
</Button>
|
||||
</div>
|
||||
{customSources.length === 0 ? (
|
||||
<Empty description="暂无自定义数据源" />
|
||||
<div className="data-source-empty-state">
|
||||
<Empty description="暂无自定义数据源" />
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
columns={customColumns}
|
||||
dataSource={customSources}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 600, y: 'auto' }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
<div ref={customTableRegionRef} className="table-scroll-region data-source-table-region">
|
||||
<Table
|
||||
columns={customColumns}
|
||||
dataSource={customSources}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 600, y: customTableHeight }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
virtual
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<h2>数据源管理</h2>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
<div className="page-shell">
|
||||
<div className="page-shell__header">
|
||||
<h2 style={{ margin: 0 }}>数据源管理</h2>
|
||||
</div>
|
||||
<div className="page-shell__body">
|
||||
<div className="data-source-tabs-shell">
|
||||
<Tabs className="data-source-tabs" activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={editingConfig ? '编辑数据源' : '添加数据源'}
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Layout,
|
||||
Menu,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Switch,
|
||||
Select,
|
||||
Divider,
|
||||
message,
|
||||
Spin,
|
||||
Tabs,
|
||||
InputNumber,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import {
|
||||
SettingOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
UserOutlined,
|
||||
BellOutlined,
|
||||
SafetyOutlined,
|
||||
SaveOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
const { Title, Text } = Typography
|
||||
const { TabPane } = Tabs
|
||||
|
||||
interface SystemSettings {
|
||||
system_name: string
|
||||
@@ -55,367 +41,293 @@ interface SecuritySettings {
|
||||
password_policy: string
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
const { user, logout, token, clearAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>({
|
||||
system_name: '智能星球',
|
||||
refresh_interval: 60,
|
||||
auto_refresh: true,
|
||||
data_retention_days: 30,
|
||||
max_concurrent_tasks: 5,
|
||||
})
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>({
|
||||
email_enabled: false,
|
||||
email_address: '',
|
||||
critical_alerts: true,
|
||||
warning_alerts: true,
|
||||
daily_summary: false,
|
||||
})
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
|
||||
session_timeout: 60,
|
||||
max_login_attempts: 5,
|
||||
password_policy: 'medium',
|
||||
})
|
||||
const [form] = Form.useForm()
|
||||
interface CollectorSettings {
|
||||
id: number
|
||||
name: string
|
||||
source: string
|
||||
module: string
|
||||
priority: string
|
||||
frequency_minutes: number
|
||||
frequency: string
|
||||
is_active: boolean
|
||||
last_run_at: string | null
|
||||
last_status: string | null
|
||||
next_run_at: string | null
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
fetchSettings()
|
||||
}, [token, navigate])
|
||||
function Settings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
|
||||
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
|
||||
const [systemForm] = Form.useForm<SystemSettings>()
|
||||
const [notificationForm] = Form.useForm<NotificationSettings>()
|
||||
const [securityForm] = Form.useForm<SecuritySettings>()
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/v1/settings/system', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.status === 401) {
|
||||
clearAuth()
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSystemSettings(data.system || systemSettings)
|
||||
setNotificationSettings(data.notifications || notificationSettings)
|
||||
setSecuritySettings(data.security || securitySettings)
|
||||
form.setFieldsValue({
|
||||
...data.system,
|
||||
...data.notifications,
|
||||
...data.security,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('获取设置失败')
|
||||
console.error(err)
|
||||
const response = await axios.get('/api/v1/settings')
|
||||
systemForm.setFieldsValue(response.data.system)
|
||||
notificationForm.setFieldsValue(response.data.notifications)
|
||||
securityForm.setFieldsValue(response.data.security)
|
||||
setCollectors(response.data.collectors || [])
|
||||
} catch (error) {
|
||||
message.error('获取系统配置失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSystem = async (values: any) => {
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [])
|
||||
|
||||
const saveSection = async (section: 'system' | 'notifications' | 'security', values: object) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch('/api/v1/settings/system', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
if (res.ok) {
|
||||
message.success('系统设置已保存')
|
||||
setSystemSettings(values)
|
||||
} else {
|
||||
message.error('保存失败')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('保存设置失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
await axios.put(`/api/v1/settings/${section}`, values)
|
||||
message.success('配置已保存')
|
||||
await fetchSettings()
|
||||
} catch (error) {
|
||||
message.error('保存失败')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveNotifications = async (values: any) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch('/api/v1/settings/notifications', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
if (res.ok) {
|
||||
message.success('通知设置已保存')
|
||||
setNotificationSettings(values)
|
||||
} else {
|
||||
message.error('保存失败')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('保存设置失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveSecurity = async (values: any) => {
|
||||
try {
|
||||
setSaving(true)
|
||||
const res = await fetch('/api/v1/settings/security', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
if (res.ok) {
|
||||
message.success('安全设置已保存')
|
||||
setSecuritySettings(values)
|
||||
} else {
|
||||
message.error('保存失败')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('保存设置失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources">数据源</Link> },
|
||||
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users">用户管理</Link> },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||
]
|
||||
|
||||
if (loading && !token) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
const updateCollectorField = (id: number, field: keyof CollectorSettings, value: string | number | boolean) => {
|
||||
setCollectors((prev) =>
|
||||
prev.map((collector) => (collector.id === id ? { ...collector, [field]: value } : collector))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="dashboard-layout">
|
||||
<Sider width={240} className="dashboard-sider">
|
||||
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title level={4} style={{ color: 'white', margin: 0 }}>智能星球</Title>
|
||||
const saveCollector = async (collector: CollectorSettings) => {
|
||||
try {
|
||||
setSavingCollectorId(collector.id)
|
||||
await axios.put(`/api/v1/settings/collectors/${collector.id}`, {
|
||||
is_active: collector.is_active,
|
||||
priority: collector.priority,
|
||||
frequency_minutes: collector.frequency_minutes,
|
||||
})
|
||||
message.success(`${collector.name} 配置已更新`)
|
||||
await fetchSettings()
|
||||
} catch (error) {
|
||||
message.error('采集调度配置保存失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
setSavingCollectorId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const collectorColumns = [
|
||||
{
|
||||
title: '数据源',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (_: string, record: CollectorSettings) => (
|
||||
<div>
|
||||
<div>{record.name}</div>
|
||||
<Text type="secondary">{record.source}</Text>
|
||||
</div>
|
||||
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/settings']} items={menuItems} />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text strong>欢迎, {user?.username}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button type="link" danger onClick={handleLogout}>退出登录</Button>
|
||||
</div>
|
||||
</Header>
|
||||
<Content className="dashboard-content">
|
||||
<Title level={3}><SettingOutlined /> 系统设置</Title>
|
||||
<Tabs defaultActiveKey="system" tabPosition="left">
|
||||
<TabPane
|
||||
tab={<span><SettingOutlined /> 系统配置</span>}
|
||||
key="system"
|
||||
>
|
||||
<Card title="基本设置">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveSystem}
|
||||
initialValues={systemSettings}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="system_name"
|
||||
label="系统名称"
|
||||
rules={[{ required: true, message: '请输入系统名称' }]}
|
||||
>
|
||||
<Input placeholder="智能星球" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="refresh_interval"
|
||||
label="数据刷新间隔 (秒)"
|
||||
>
|
||||
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="data_retention_days"
|
||||
label="数据保留天数"
|
||||
>
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="max_concurrent_tasks"
|
||||
label="最大并发任务数"
|
||||
>
|
||||
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item
|
||||
name="auto_refresh"
|
||||
label="自动刷新"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><BellOutlined /> 通知设置</span>}
|
||||
key="notifications"
|
||||
>
|
||||
<Card title="通知配置">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveNotifications}
|
||||
initialValues={notificationSettings}
|
||||
>
|
||||
<Divider orientation="left">邮件通知</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="email_enabled"
|
||||
label="启用邮件通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="email_address"
|
||||
label="通知邮箱"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
>
|
||||
<Input placeholder="admin@example.com" disabled={!notificationSettings.email_enabled} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider orientation="left">告警通知</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="critical_alerts"
|
||||
label="严重告警"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="warning_alerts"
|
||||
label="警告告警"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="daily_summary"
|
||||
label="每日摘要"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><SafetyOutlined /> 安全设置</span>}
|
||||
key="security"
|
||||
>
|
||||
<Card title="安全配置">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveSecurity}
|
||||
initialValues={securitySettings}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="session_timeout"
|
||||
label="会话超时 (分钟)"
|
||||
>
|
||||
<InputNumber min={5} max={1440} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="max_login_attempts"
|
||||
label="最大登录尝试次数"
|
||||
>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item
|
||||
name="password_policy"
|
||||
label="密码策略"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="low">简单 (最低6位)</Select.Option>
|
||||
<Select.Option value="medium">中等 (8位以上,含数字字母)</Select.Option>
|
||||
<Select.Option value="high">严格 (12位以上,含大小写数字特殊字符)</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 90,
|
||||
render: (module: string) => <Tag color="blue">{module}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '优先级',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
width: 130,
|
||||
render: (priority: string, record: CollectorSettings) => (
|
||||
<Select
|
||||
value={priority}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => updateCollectorField(record.id, 'priority', value)}
|
||||
options={[
|
||||
{ value: 'P0', label: 'P0' },
|
||||
{ value: 'P1', label: 'P1' },
|
||||
{ value: 'P2', label: 'P2' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '频率(分钟)',
|
||||
dataIndex: 'frequency_minutes',
|
||||
key: 'frequency_minutes',
|
||||
width: 150,
|
||||
render: (value: number, record: CollectorSettings) => (
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10080}
|
||||
value={value}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(nextValue) => updateCollectorField(record.id, 'frequency_minutes', nextValue || 1)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'is_active',
|
||||
key: 'is_active',
|
||||
width: 90,
|
||||
render: (value: boolean, record: CollectorSettings) => (
|
||||
<Switch checked={value} onChange={(checked) => updateCollectorField(record.id, 'is_active', checked)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '上次执行',
|
||||
dataIndex: 'last_run_at',
|
||||
key: 'last_run_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '下次执行',
|
||||
dataIndex: 'next_run_at',
|
||||
key: 'next_run_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'last_status',
|
||||
key: 'last_status',
|
||||
width: 120,
|
||||
render: (value: string | null) => {
|
||||
if (!value) return <Tag>未执行</Tag>
|
||||
const color = value === 'success' ? 'success' : value === 'failed' ? 'error' : 'default'
|
||||
return <Tag color={color}>{value}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: CollectorSettings) => (
|
||||
<Button type="primary" loading={savingCollectorId === record.id} onClick={() => saveCollector(record)}>
|
||||
保存
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>系统配置中心</Title>
|
||||
<Text type="secondary">这一页现在已经直接连接数据库配置和采集调度,不再只是演示表单。</Text>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统显示',
|
||||
children: (
|
||||
<Card loading={loading}>
|
||||
<Form form={systemForm} layout="vertical" onFinish={(values) => saveSection('system', values)}>
|
||||
<Form.Item name="system_name" label="系统名称" rules={[{ required: true, message: '请输入系统名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="refresh_interval" label="默认刷新间隔(秒)">
|
||||
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="data_retention_days" label="数据保留天数">
|
||||
<InputNumber min={1} max={3650} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="max_concurrent_tasks" label="最大并发任务数">
|
||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="auto_refresh" label="自动刷新" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">保存系统配置</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'notifications',
|
||||
label: '通知策略',
|
||||
children: (
|
||||
<Card loading={loading}>
|
||||
<Form form={notificationForm} layout="vertical" onFinish={(values) => saveSection('notifications', values)}>
|
||||
<Form.Item name="email_enabled" label="启用邮件通知" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="email_address" label="通知邮箱">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="critical_alerts" label="严重告警通知" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="warning_alerts" label="警告告警通知" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="daily_summary" label="每日摘要" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">保存通知配置</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
label: '安全策略',
|
||||
children: (
|
||||
<Card loading={loading}>
|
||||
<Form form={securityForm} layout="vertical" onFinish={(values) => saveSection('security', values)}>
|
||||
<Form.Item name="session_timeout" label="会话超时(分钟)">
|
||||
<InputNumber min={5} max={1440} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="max_login_attempts" label="最大登录尝试次数">
|
||||
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="password_policy" label="密码策略">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'low', label: '简单' },
|
||||
{ value: 'medium', label: '中等' },
|
||||
{ value: 'high', label: '严格' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">保存安全配置</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'collectors',
|
||||
label: '采集调度',
|
||||
children: (
|
||||
<Card loading={loading}>
|
||||
<div className="table-scroll-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={collectorColumns}
|
||||
dataSource={collectors}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200, y: 'calc(100% - 360px)' }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
|
||||
|
||||
@@ -145,7 +145,9 @@ function Tasks() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
|
||||
<div className="table-scroll-region">
|
||||
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
||||
</div>
|
||||
</Card>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -115,11 +115,17 @@ function Users() {
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<h2>用户管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||
<div className="page-shell">
|
||||
<div className="page-shell__header">
|
||||
<h2 style={{ margin: 0 }}>用户管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||
</div>
|
||||
<div className="page-shell__body">
|
||||
<div className="table-scroll-region" style={{ height: '100%' }}>
|
||||
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} scroll={{ x: 'max-content', y: 'calc(100% - 72px)' }} tableLayout="fixed" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} scroll={{ x: 'max-content' }} tableLayout="fixed" />
|
||||
<Modal
|
||||
title={editingUser ? '编辑用户' : '添加用户'}
|
||||
open={modalVisible}
|
||||
|
||||
Reference in New Issue
Block a user