diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 51c04353..d4b73889 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -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} @@ -417,4 +269,4 @@ async def get_task_status( "records_processed": running_task.records_processed, "total_records": running_task.total_records, "status": running_task.status, - } + } diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index feeda38c..cdde6d2a 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -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", + } diff --git a/backend/app/core/datasource_defaults.py b/backend/app/core/datasource_defaults.py new file mode 100644 index 00000000..7c5f9430 --- /dev/null +++ b/backend/app/core/datasource_defaults.py @@ -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()} diff --git a/backend/app/db/session.py b/backend/app/db/session.py index f1bcc83c..392ca380 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 82fee24c..3bd9aa02 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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()} + return {"jobs": get_scheduler_jobs()} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cf662d94..16dc63d4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,14 +2,18 @@ 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", -] +] diff --git a/backend/app/models/system_setting.py b/backend/app/models/system_setting.py new file mode 100644 index 00000000..ab143348 --- /dev/null +++ b/backend/app/models/system_setting.py @@ -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"" diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 58762e11..3932ca16 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -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}") - return False + except Exception as exc: + logger.error("Failed to trigger collector %s: %s", collector_name, exc) + return False diff --git a/docs/system-settings-plan.md b/docs/system-settings-plan.md new file mode 100644 index 00000000..8d7d8952 --- /dev/null +++ b/docs/system-settings-plan.md @@ -0,0 +1,47 @@ +# 系统配置中心开发计划 + +## 目标 + +将当前仅保存于内存中的“系统配置”页面升级为真正可用的配置中心,优先服务以下两类能力: + +1. 系统级配置持久化 +2. 采集调度配置管理 + +## 第一阶段范围 + +### 1. 系统配置持久化 + +- 新增 `system_settings` 表,用于保存分类配置 +- 将系统、通知、安全配置从进程内存迁移到数据库 +- 提供统一读取接口,页面刷新和服务重启后保持不丢失 + +### 2. 采集调度配置接入真实数据源 + +- 统一内置采集器默认定义 +- 启动时自动初始化 `data_sources` 表 +- 配置页允许修改: + - 是否启用 + - 采集频率(分钟) + - 优先级 +- 修改后实时同步到调度器 + +### 3. 前端配置页重构 + +- 将当前通用模板页调整为项目专用配置中心 +- 增加“采集调度”Tab +- 保留“系统显示 / 通知 / 安全”三类配置 +- 将设置页正式接入主路由 + +## 非本阶段内容 + +- 邮件发送能力本身 +- 配置审计历史 +- 敏感凭证加密管理 +- 多租户或按角色细粒度配置 + +## 验收标准 + +- 设置项修改后重启服务仍然存在 +- 配置页可以查看并修改所有内置采集器的启停与采集频率 +- 调整采集频率后,调度器任务随之更新 +- `/settings` 页面可从主导航进入并正常工作 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e041816..aeb7a8e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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,9 +24,10 @@ function App() { } /> } /> } /> + } /> } /> ) } -export default App +export default App diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 5ee28271..74167315 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -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: , label: 仪表盘 }, @@ -34,43 +35,56 @@ function AppLayout({ children }: AppLayoutProps) { return ( - -
- {collapsed ? ( - 🌏 - ) : ( - 智能星球 - )} -
- - - -
- +
+
+
setCollapsed(false) : undefined}> +
+
-
- - {children} + + {showBanner && !collapsed ? ( +
+ +
+ 当前账号 + {user?.username} +
+ +
+
+ ) : null} + + + + +
{children}
diff --git a/frontend/src/index.css b/frontend/src/index.css index fb11001b..27d0a568 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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%; + } + +} diff --git a/frontend/src/pages/Alerts/Alerts.tsx b/frontend/src/pages/Alerts/Alerts.tsx index 939a61bc..e3225336 100644 --- a/frontend/src/pages/Alerts/Alerts.tsx +++ b/frontend/src/pages/Alerts/Alerts.tsx @@ -174,7 +174,9 @@ function Alerts() { title="告警列表" extra={} > - +
+
+ (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: , label: 仪表盘 }, - { key: '/datasources', icon: , label: 数据源 }, - { key: '/data', icon: , label: 采集数据 }, - { key: '/users', icon: , label: 用户管理 }, - { key: '/settings', icon: , label: '系统配置' }, - ] - if (loading && !stats) { return (
@@ -143,81 +121,78 @@ function Dashboard() { } return ( - - -
- 智能星球 -
- - - -
- 欢迎, {user?.username} -
+ +
+
+
+ 仪表盘 + 系统总览与实时态势 +
+ {wsConnected ? ( } color="success">实时连接 ) : ( } color="default">离线 )} - - - -
-
- - {error && ( - - {error} + + +
+ + {error && ( + + {error} + + )} + + +
+ + } /> - )} - - - - } /> - - - - - - - - - - } /> - - - - - - - - - - - - } /> - - - - - } /> - - - - - } /> - - - - {stats?.last_updated && ( -
- 最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')} - {wsConnected && 实时同步中} -
- )} - - - + + + + + + + + + } /> + + + + + + + + + + + + + } /> + + + + + } /> + + + + + } /> + + + + + {stats?.last_updated && ( +
+ 最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')} + {wsConnected && 实时同步中} +
+ )} + + ) } diff --git a/frontend/src/pages/DataList/DataList.tsx b/frontend/src/pages/DataList/DataList.tsx index bcc1567f..f2f37f9e 100644 --- a/frontend/src/pages/DataList/DataList.tsx +++ b/frontend/src/pages/DataList/DataList.tsx @@ -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(null) + const workspaceRef = useRef(null) + const mainAreaRef = useRef(null) + const rightColumnRef = useRef(null) + const tableHeaderRef = useRef(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([]) const [loading, setLoading] = useState(false) const [summary, setSummary] = useState(null) @@ -55,6 +73,73 @@ function DataList() { const [detailData, setDetailData] = useState(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) => { + 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 = { - 'top500': , - 'huggingface_models': , - 'huggingface_datasets': , - 'huggingface_spaces': , - 'telegeography_cables': , - 'epoch_ai_gpu': , + top500: , + huggingface_models: , + huggingface_datasets: , + huggingface_spaces: , + telegeography_cables: , + epoch_ai_gpu: , } return iconMap[source] || } const getTypeColor = (type: string) => { const colors: Record = { - '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>({ - 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: }, + { key: 'result', label: '筛选结果', value: total, icon: }, + { key: 'filters', label: '启用筛选', value: activeFilterCount, icon: }, + { key: 'sources', label: '数据源数', value: sources.length, icon: }, + ] - 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 = [ + { title: 'ID', dataIndex: 'id', key: 'id', width: 80 }, { - title: () => ( -
- ID -
e.stopPropagation()} - /> -
- ), - dataIndex: 'id', - key: 'id', - width: columnsWidth.id, - }, - { - title: () => ( -
- 名称 -
e.stopPropagation()} - /> -
- ), + title: '名称', dataIndex: 'name', key: 'name', - width: columnsWidth.name, + width: 280, ellipsis: true, render: (name: string, record: CollectedData) => ( @@ -236,101 +343,40 @@ function DataList() { ), }, { - title: () => ( -
- 数据源 -
e.stopPropagation()} - /> -
- ), + title: '数据源', dataIndex: 'source', key: 'source', - width: columnsWidth.source, - render: (source: string) => ( - {source} - ), + width: 170, + render: (source: string) => {source}, }, { - title: () => ( -
- 类型 -
e.stopPropagation()} - /> -
- ), + title: '类型', dataIndex: 'data_type', key: 'data_type', - width: columnsWidth.data_type, - render: (type: string) => ( - {type} - ), + width: 120, + render: (type: string) => {type}, }, + { title: '国家/地区', dataIndex: 'country', key: 'country', width: 130, ellipsis: true }, { - title: () => ( -
- 国家/地区 -
e.stopPropagation()} - /> -
- ), - dataIndex: 'country', - key: 'country', - width: columnsWidth.country, - ellipsis: true, - }, - { - title: () => ( -
- 数值 -
e.stopPropagation()} - /> -
- ), + 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: () => ( -
- 采集时间 -
e.stopPropagation()} - /> -
- ), + 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) => ( - ), @@ -339,93 +385,160 @@ function DataList() { return ( -

采集数据管理

+
+
+
+ 采集数据 +
+ + + 结果 {total.toLocaleString()} 条 + + + 筛选 {activeFilterCount} 项 + + +
- {/* Summary Cards */} - {summary && ( - -
- - } - /> +
+
+ +
+ {treemapItems.map((item) => ( +
+
+ {item.icon} + {item.label} +
+
+ + {item.value.toLocaleString()} + +
+
+ ))} +
- - {summary.source_totals.slice(0, 4).map((item) => ( -
- - + + {!isCompact && ( +
+ )} + +
+ +
+
+ + 数据列表 + 共 {total.toLocaleString()} 条结果 + + + + + + +
+
+ { + setTypeFilter(value) + setPage(1) + }} + options={types.map((type) => ({ label: type, value: type }))} + style={{ width: '100%' }} + /> + setSearchText(event.target.value)} + onPressEnter={handleSearch} + /> +
+
+
+
{ + setPage(nextPage) + setPageSize(nextPageSize) + }, + showSizeChanger: true, + showTotal: (count) => `共 ${count} 条`, + }} + /> + - - ))} - - )} + + + + - {/* Filters */} - - - { setTypeFilter(v); setPage(1); }} - options={types.map(t => ({ label: t, value: t }))} - /> - setSearchText(e.target.value)} - onPressEnter={handleSearch} - /> - - - - - {/* Data Table */} -
{ setPage(p); setPageSize(ps); }, - showSizeChanger: true, - showTotal: (t) => `共 ${t} 条`, - }} - /> - - {/* Detail Modal */} (0) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState(null) + const builtinTableRegionRef = useRef(null) + const customTableRegionRef = useRef(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: ( -
+
+
+
+ + ), }, { @@ -460,35 +491,48 @@ function DataSources() { ), children: ( - <> -
+
+
{customSources.length === 0 ? ( - +
+ +
) : ( -
+
+
+ )} - + ), }, ] return ( -

数据源管理

- +
+
+

数据源管理

+
+
+
+ +
+
+
({ - system_name: '智能星球', - refresh_interval: 60, - auto_refresh: true, - data_retention_days: 30, - max_concurrent_tasks: 5, - }) - const [notificationSettings, setNotificationSettings] = useState({ - email_enabled: false, - email_address: '', - critical_alerts: true, - warning_alerts: true, - daily_summary: false, - }) - const [securitySettings, setSecuritySettings] = useState({ - 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(null) + const [collectors, setCollectors] = useState([]) + const [systemForm] = Form.useForm() + const [notificationForm] = Form.useForm() + const [securityForm] = Form.useForm() 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: , label: 仪表盘 }, - { key: '/datasources', icon: , label: 数据源 }, - { key: '/users', icon: , label: 用户管理 }, - { key: '/settings', icon: , label: '系统配置' }, - ] - - if (loading && !token) { - return ( -
- -
+ const updateCollectorField = (id: number, field: keyof CollectorSettings, value: string | number | boolean) => { + setCollectors((prev) => + prev.map((collector) => (collector.id === id ? { ...collector, [field]: value } : collector)) ) } - return ( - - -
- 智能星球 + 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) => ( +
+
{record.name}
+ {record.source}
- - - -
- 欢迎, {user?.username} -
- -
-
- - <SettingOutlined /> 系统设置 - - 系统配置} - key="system" - > - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 通知设置} - key="notifications" - > - -
- 邮件通知 - -
- - - - - - - - - - - 告警通知 - - - - - - - - - - - - - - - - - - - - - - - - 安全设置} - key="security" - > - -
- -
- - - - - - - - - - - - - - - - - - - - - - + ), + }, + { + title: '层级', + dataIndex: 'module', + key: 'module', + width: 90, + render: (module: string) => {module}, + }, + { + title: '优先级', + dataIndex: 'priority', + key: 'priority', + width: 130, + render: (priority: string, record: CollectorSettings) => ( + + + + + + + + + + + + + + + + + + ), + }, + { + key: 'notifications', + label: '通知策略', + children: ( + +
saveSection('notifications', values)}> + + + + + + + + + + + + + + + + + +
+ ), + }, + { + key: 'security', + label: '安全策略', + children: ( + +
saveSection('security', values)}> + + + + + + + +
+ + + ), + }, + ]} + /> + + ) } export default Settings + diff --git a/frontend/src/pages/Tasks/Tasks.tsx b/frontend/src/pages/Tasks/Tasks.tsx index 79bcd106..8af549db 100644 --- a/frontend/src/pages/Tasks/Tasks.tsx +++ b/frontend/src/pages/Tasks/Tasks.tsx @@ -145,7 +145,9 @@ function Tasks() { } > -
+
+
+ ) diff --git a/frontend/src/pages/Users/Users.tsx b/frontend/src/pages/Users/Users.tsx index f9cc7848..7281dc53 100644 --- a/frontend/src/pages/Users/Users.tsx +++ b/frontend/src/pages/Users/Users.tsx @@ -115,11 +115,17 @@ function Users() { return ( -
-

用户管理

- +
+
+

用户管理

+ +
+
+
+
+ + -