Add force rerun recovery and polish CLI startup
This commit is contained in:
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.time import to_iso8601_utc
|
from app.core.time import to_iso8601_utc
|
||||||
@@ -11,10 +11,16 @@ from app.core.security import get_current_user
|
|||||||
from app.core.data_sources import get_data_sources_config
|
from app.core.data_sources import get_data_sources_config
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.collected_data import CollectedData
|
from app.models.collected_data import CollectedData
|
||||||
|
from app.models.data_snapshot import DataSnapshot
|
||||||
from app.models.datasource import DataSource
|
from app.models.datasource import DataSource
|
||||||
from app.models.task import CollectionTask
|
from app.models.task import CollectionTask
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.scheduler import get_latest_task_id_for_datasource, run_collector_now, sync_datasource_job
|
from app.services.scheduler import (
|
||||||
|
cancel_running_collector_now,
|
||||||
|
get_latest_task_id_for_datasource,
|
||||||
|
run_collector_now,
|
||||||
|
sync_datasource_job,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
|
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
|
||||||
@@ -100,6 +106,70 @@ async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[Col
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def rollback_orphaned_running_task(
|
||||||
|
db: AsyncSession,
|
||||||
|
datasource: DataSource,
|
||||||
|
running_task: CollectionTask,
|
||||||
|
) -> None:
|
||||||
|
snapshot_result = await db.execute(
|
||||||
|
select(DataSnapshot)
|
||||||
|
.where(
|
||||||
|
DataSnapshot.datasource_id == datasource.id,
|
||||||
|
DataSnapshot.task_id == running_task.id,
|
||||||
|
)
|
||||||
|
.order_by(DataSnapshot.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
snapshot = snapshot_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
await db.execute(CollectedData.__table__.delete().where(CollectedData.task_id == running_task.id))
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE collected_data
|
||||||
|
SET is_current = FALSE
|
||||||
|
WHERE source = :source
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"source": datasource.source},
|
||||||
|
)
|
||||||
|
|
||||||
|
if snapshot is not None:
|
||||||
|
snapshot.status = "cancelled"
|
||||||
|
snapshot.is_current = False
|
||||||
|
snapshot.completed_at = datetime.now(timezone.utc)
|
||||||
|
summary = dict(snapshot.summary or {})
|
||||||
|
summary["rollback"] = True
|
||||||
|
summary["rollback_reason"] = "orphaned_running_task_after_backend_restart"
|
||||||
|
snapshot.summary = summary
|
||||||
|
|
||||||
|
if snapshot.parent_snapshot_id is not None:
|
||||||
|
parent_snapshot = await db.get(DataSnapshot, snapshot.parent_snapshot_id)
|
||||||
|
if parent_snapshot:
|
||||||
|
parent_snapshot.is_current = True
|
||||||
|
await db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE collected_data
|
||||||
|
SET is_current = TRUE
|
||||||
|
WHERE snapshot_id = :snapshot_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"snapshot_id": snapshot.parent_snapshot_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
running_task.status = "cancelled"
|
||||||
|
running_task.phase = "cancelled"
|
||||||
|
running_task.completed_at = datetime.now(timezone.utc)
|
||||||
|
existing_error = (running_task.error_message or "").strip()
|
||||||
|
cancel_reason = "Cancelled after backend restart because the running task handle was lost; incomplete writes rolled back"
|
||||||
|
running_task.error_message = f"{existing_error}\n{cancel_reason}".strip() if existing_error else cancel_reason
|
||||||
|
datasource.last_status = "cancelled"
|
||||||
|
datasource.last_run_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_datasources(
|
async def list_datasources(
|
||||||
module: Optional[str] = None,
|
module: Optional[str] = None,
|
||||||
@@ -346,6 +416,7 @@ async def get_datasource_stats(
|
|||||||
@router.post("/{source_id}/trigger")
|
@router.post("/{source_id}/trigger")
|
||||||
async def trigger_datasource(
|
async def trigger_datasource(
|
||||||
source_id: str,
|
source_id: str,
|
||||||
|
force: bool = Query(False),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -356,6 +427,26 @@ async def trigger_datasource(
|
|||||||
if not datasource.is_active:
|
if not datasource.is_active:
|
||||||
raise HTTPException(status_code=400, detail="Data source is disabled")
|
raise HTTPException(status_code=400, detail="Data source is disabled")
|
||||||
|
|
||||||
|
running_task = await get_running_task(db, datasource.id)
|
||||||
|
if running_task is not None and not force:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"reason": "running_task_in_progress",
|
||||||
|
"message": "当前采集任务尚未完成,重新触发会丢失本次未完成进度。是否强制重新采集?",
|
||||||
|
"task_id": running_task.id,
|
||||||
|
"phase": running_task.phase,
|
||||||
|
"progress": running_task.progress,
|
||||||
|
"records_processed": running_task.records_processed,
|
||||||
|
"total_records": running_task.total_records,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if running_task is not None and force:
|
||||||
|
cancelled = await cancel_running_collector_now(datasource.source)
|
||||||
|
if not cancelled:
|
||||||
|
await rollback_orphaned_running_task(db, datasource, running_task)
|
||||||
|
|
||||||
previous_task_id = await get_latest_task_id_for_datasource(datasource.id)
|
previous_task_id = await get_latest_task_id_for_datasource(datasource.id)
|
||||||
success = run_collector_now(datasource.source)
|
success = run_collector_now(datasource.source)
|
||||||
if not success:
|
if not success:
|
||||||
@@ -375,6 +466,7 @@ async def trigger_datasource(
|
|||||||
"source_id": datasource.id,
|
"source_id": datasource.id,
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"collector_name": datasource.source,
|
"collector_name": datasource.source,
|
||||||
|
"force": force,
|
||||||
"message": f"Collector '{datasource.source}' has been triggered",
|
"message": f"Collector '{datasource.source}' has been triggered",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Base collector class for all data sources"""
|
"""Base collector class for all data sources"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
@@ -166,6 +167,59 @@ class BaseCollector(ABC):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
return snapshot.id
|
return snapshot.id
|
||||||
|
|
||||||
|
async def _rollback_incomplete_run(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
task_id: int,
|
||||||
|
snapshot_id: Optional[int],
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
from app.models.collected_data import CollectedData
|
||||||
|
from app.models.data_snapshot import DataSnapshot
|
||||||
|
|
||||||
|
await db.execute(CollectedData.__table__.delete().where(CollectedData.task_id == task_id))
|
||||||
|
|
||||||
|
parent_snapshot_id: Optional[int] = None
|
||||||
|
if snapshot_id is not None:
|
||||||
|
snapshot = await db.get(DataSnapshot, snapshot_id)
|
||||||
|
if snapshot:
|
||||||
|
parent_snapshot_id = snapshot.parent_snapshot_id
|
||||||
|
snapshot.status = "cancelled"
|
||||||
|
snapshot.is_current = False
|
||||||
|
snapshot.completed_at = datetime.now(UTC)
|
||||||
|
summary = dict(snapshot.summary or {})
|
||||||
|
summary["rollback"] = True
|
||||||
|
summary["rollback_reason"] = reason
|
||||||
|
snapshot.summary = summary
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE collected_data
|
||||||
|
SET is_current = FALSE
|
||||||
|
WHERE source = :source
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"source": self.name},
|
||||||
|
)
|
||||||
|
|
||||||
|
if parent_snapshot_id is not None:
|
||||||
|
parent_snapshot = await db.get(DataSnapshot, parent_snapshot_id)
|
||||||
|
if parent_snapshot:
|
||||||
|
parent_snapshot.is_current = True
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE collected_data
|
||||||
|
SET is_current = TRUE
|
||||||
|
WHERE snapshot_id = :snapshot_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"snapshot_id": parent_snapshot_id},
|
||||||
|
)
|
||||||
|
|
||||||
async def run(self, db: AsyncSession) -> Dict[str, Any]:
|
async def run(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
"""Full pipeline: fetch -> transform -> save"""
|
"""Full pipeline: fetch -> transform -> save"""
|
||||||
from app.services.collectors.registry import collector_registry
|
from app.services.collectors.registry import collector_registry
|
||||||
@@ -227,6 +281,21 @@ class BaseCollector(ABC):
|
|||||||
"records_processed": records_count,
|
"records_processed": records_count,
|
||||||
"execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(),
|
"execution_time_seconds": (datetime.now(UTC) - start_time).total_seconds(),
|
||||||
}
|
}
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
task.status = "cancelled"
|
||||||
|
task.phase = "cancelled"
|
||||||
|
task.error_message = "Collection cancelled by operator and rolled back"
|
||||||
|
task.completed_at = datetime.now(UTC)
|
||||||
|
if snapshot_id is not None:
|
||||||
|
await self._rollback_incomplete_run(
|
||||||
|
db,
|
||||||
|
task_id=task_id,
|
||||||
|
snapshot_id=snapshot_id,
|
||||||
|
reason="cancelled_by_operator",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await self._publish_task_update(force=True)
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
task.status = "failed"
|
task.status = "failed"
|
||||||
task.phase = "failed"
|
task.phase = "failed"
|
||||||
@@ -276,20 +345,34 @@ class BaseCollector(ABC):
|
|||||||
updated_count = 0
|
updated_count = 0
|
||||||
unchanged_count = 0
|
unchanged_count = 0
|
||||||
seen_entity_keys: set[str] = set()
|
seen_entity_keys: set[str] = set()
|
||||||
previous_current_keys: set[str] = set()
|
progress_commit_interval = 1000
|
||||||
|
|
||||||
previous_current_result = await db.execute(
|
previous_current_result = await db.execute(
|
||||||
select(CollectedData.entity_key).where(
|
select(CollectedData)
|
||||||
|
.where(
|
||||||
CollectedData.source == self.name,
|
CollectedData.source == self.name,
|
||||||
CollectedData.is_current == True,
|
CollectedData.is_current == True,
|
||||||
)
|
)
|
||||||
|
.order_by(CollectedData.entity_key.asc(), CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc())
|
||||||
)
|
)
|
||||||
previous_current_keys = {row[0] for row in previous_current_result.fetchall() if row[0]}
|
previous_current_records = previous_current_result.scalars().all()
|
||||||
|
previous_current_keys = {record.entity_key for record in previous_current_records if record.entity_key}
|
||||||
|
previous_current_map: dict[str, CollectedData] = {}
|
||||||
|
stale_previous_records: list[CollectedData] = []
|
||||||
|
|
||||||
|
for existing_record in previous_current_records:
|
||||||
|
entity_key = existing_record.entity_key
|
||||||
|
if not entity_key:
|
||||||
|
continue
|
||||||
|
if entity_key not in previous_current_map:
|
||||||
|
previous_current_map[entity_key] = existing_record
|
||||||
|
continue
|
||||||
|
stale_previous_records.append(existing_record)
|
||||||
|
|
||||||
|
for stale_record in stale_previous_records:
|
||||||
|
stale_record.is_current = False
|
||||||
|
|
||||||
for i, item in enumerate(data):
|
for i, item in enumerate(data):
|
||||||
print(
|
|
||||||
f"DEBUG: Saving item {i}: name={item.get('name')}, metadata={item.get('metadata', 'NOT FOUND')}"
|
|
||||||
)
|
|
||||||
raw_metadata = item.get("metadata", {})
|
raw_metadata = item.get("metadata", {})
|
||||||
extra_data = build_dynamic_metadata(
|
extra_data = build_dynamic_metadata(
|
||||||
raw_metadata,
|
raw_metadata,
|
||||||
@@ -318,20 +401,9 @@ class BaseCollector(ABC):
|
|||||||
previous_record = None
|
previous_record = None
|
||||||
|
|
||||||
if entity_key and entity_key not in seen_entity_keys:
|
if entity_key and entity_key not in seen_entity_keys:
|
||||||
result = await db.execute(
|
previous_record = previous_current_map.get(entity_key)
|
||||||
select(CollectedData)
|
if previous_record is not None:
|
||||||
.where(
|
previous_record.is_current = False
|
||||||
CollectedData.source == self.name,
|
|
||||||
CollectedData.entity_key == entity_key,
|
|
||||||
CollectedData.is_current == True,
|
|
||||||
)
|
|
||||||
.order_by(CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc())
|
|
||||||
)
|
|
||||||
previous_records = result.scalars().all()
|
|
||||||
if previous_records:
|
|
||||||
previous_record = previous_records[0]
|
|
||||||
for old_record in previous_records:
|
|
||||||
old_record.is_current = False
|
|
||||||
|
|
||||||
record = CollectedData(
|
record = CollectedData(
|
||||||
snapshot_id=snapshot_id,
|
snapshot_id=snapshot_id,
|
||||||
@@ -375,7 +447,7 @@ class BaseCollector(ABC):
|
|||||||
seen_entity_keys.add(entity_key)
|
seen_entity_keys.add(entity_key)
|
||||||
records_added += 1
|
records_added += 1
|
||||||
|
|
||||||
if i % 100 == 0:
|
if (i + 1) % progress_commit_interval == 0:
|
||||||
await self.update_progress(i + 1, commit=True)
|
await self.update_progress(i + 1, commit=True)
|
||||||
|
|
||||||
if snapshot_id is not None:
|
if snapshot_id is not None:
|
||||||
|
|||||||
@@ -19,6 +19,30 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
|
RUNNING_TASK_GUARD_TIMEOUT_MINUTES = 90
|
||||||
|
RUNNING_COLLECTOR_TASKS: dict[str, asyncio.Task[Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _collector_task_name(collector_name: str) -> str:
|
||||||
|
return f"collector:{collector_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_running_collector_task(collector_name: str) -> asyncio.Task[Any] | None:
|
||||||
|
task = RUNNING_COLLECTOR_TASKS.get(collector_name)
|
||||||
|
if task is not None and not task.done():
|
||||||
|
return task
|
||||||
|
|
||||||
|
if task is not None and task.done():
|
||||||
|
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
||||||
|
|
||||||
|
target_name = _collector_task_name(collector_name)
|
||||||
|
for candidate in asyncio.all_tasks():
|
||||||
|
if candidate.done():
|
||||||
|
continue
|
||||||
|
if candidate.get_name() == target_name:
|
||||||
|
RUNNING_COLLECTOR_TASKS[collector_name] = candidate
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _update_next_run_at(datasource: DataSource, session) -> None:
|
async def _update_next_run_at(datasource: DataSource, session) -> None:
|
||||||
@@ -133,6 +157,12 @@ async def run_collector_task(collector_name: str):
|
|||||||
datasource.last_status = task_result.get("status")
|
datasource.last_status = task_result.get("status")
|
||||||
await _update_next_run_at(datasource, db)
|
await _update_next_run_at(datasource, db)
|
||||||
logger.info("Collector %s completed: %s", collector_name, task_result)
|
logger.info("Collector %s completed: %s", collector_name, task_result)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
datasource.last_run_at = datetime.now(UTC)
|
||||||
|
datasource.last_status = "cancelled"
|
||||||
|
await db.commit()
|
||||||
|
logger.warning("Collector %s cancelled by operator", collector_name)
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
datasource.last_run_at = datetime.now(UTC)
|
datasource.last_run_at = datetime.now(UTC)
|
||||||
datasource.last_status = "failed"
|
datasource.last_status = "failed"
|
||||||
@@ -245,9 +275,31 @@ def run_collector_now(collector_name: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.create_task(run_collector_task(collector_name))
|
task = asyncio.create_task(run_collector_task(collector_name), name=_collector_task_name(collector_name))
|
||||||
|
RUNNING_COLLECTOR_TASKS[collector_name] = task
|
||||||
|
|
||||||
|
def _cleanup_task(done_task: asyncio.Task[Any]) -> None:
|
||||||
|
current = RUNNING_COLLECTOR_TASKS.get(collector_name)
|
||||||
|
if current is done_task:
|
||||||
|
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
||||||
|
|
||||||
|
task.add_done_callback(_cleanup_task)
|
||||||
logger.info("Triggered collector: %s", collector_name)
|
logger.info("Triggered collector: %s", collector_name)
|
||||||
return True
|
return True
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
|
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_running_collector_now(collector_name: str) -> bool:
|
||||||
|
task = get_running_collector_task(collector_name)
|
||||||
|
if task is None or task.done():
|
||||||
|
RUNNING_COLLECTOR_TASKS.pop(collector_name, None)
|
||||||
|
return False
|
||||||
|
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return True
|
||||||
|
return task.cancelled()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message,
|
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
|
||||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
|
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import {
|
import {
|
||||||
@@ -382,10 +382,12 @@ function DataSources() {
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
|
}, [builtInSources, taskProgress, taskSocketConnected, fetchData])
|
||||||
|
|
||||||
const handleTrigger = async (id: number) => {
|
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
|
||||||
try {
|
const force = options?.force ?? false
|
||||||
const res = await axios.post(`/api/v1/datasources/${id}/trigger`)
|
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
|
||||||
messageApi.success('任务已触发')
|
params: { force },
|
||||||
|
})
|
||||||
|
|
||||||
if (res.data.task_id) {
|
if (res.data.task_id) {
|
||||||
setTaskProgress(prev => ({
|
setTaskProgress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -402,10 +404,70 @@ function DataSources() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, 800)
|
}, 800)
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchData()
|
fetchData()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmForceTrigger = (id: number, taskInfo?: {
|
||||||
|
progress?: number | null
|
||||||
|
phase?: string | null
|
||||||
|
records_processed?: number | null
|
||||||
|
total_records?: number | null
|
||||||
|
message?: string
|
||||||
|
}) => {
|
||||||
|
const progressText = typeof taskInfo?.progress === 'number' ? `${Math.round(taskInfo.progress)}%` : '未知'
|
||||||
|
const phaseText = taskInfo?.phase || 'running'
|
||||||
|
const processedText = typeof taskInfo?.records_processed === 'number'
|
||||||
|
? `${taskInfo.records_processed}${typeof taskInfo?.total_records === 'number' && taskInfo.total_records > 0 ? ` / ${taskInfo.total_records}` : ''}`
|
||||||
|
: '未知'
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: '当前任务未完成',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<p>{taskInfo?.message || '当前采集任务仍在运行,重新触发会丢失本次未完成进度。'}</p>
|
||||||
|
<p>当前阶段: {phaseText}</p>
|
||||||
|
<p>当前进度: {progressText}</p>
|
||||||
|
<p>已处理记录: {processedText}</p>
|
||||||
|
<p>确认后会强制取消当前采集,并回滚未完成写入,然后重新开始采集。</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
okText: '强制重新采集',
|
||||||
|
cancelText: '取消',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await triggerDatasource(id, { force: true })
|
||||||
|
messageApi.success('已强制重新触发,未完成采集将回滚')
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string } } }
|
const err = error as { response?: { data?: { detail?: string | { message?: string } } } }
|
||||||
messageApi.error(err.response?.data?.detail || '触发失败')
|
const detail = err.response?.data?.detail
|
||||||
|
messageApi.error(typeof detail === 'string' ? detail : detail?.message || '强制重新采集失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTrigger = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await triggerDatasource(id)
|
||||||
|
messageApi.success('任务已触发')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as { response?: { status?: number; data?: { detail?: string | {
|
||||||
|
reason?: string
|
||||||
|
message?: string
|
||||||
|
progress?: number | null
|
||||||
|
phase?: string | null
|
||||||
|
records_processed?: number | null
|
||||||
|
total_records?: number | null
|
||||||
|
} } } }
|
||||||
|
const detail = err.response?.data?.detail
|
||||||
|
if (err.response?.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') {
|
||||||
|
confirmForceTrigger(id, detail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageApi.error(typeof detail === 'string' ? detail : detail?.message || '触发失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,12 +589,24 @@ function DataSources() {
|
|||||||
const handleUpdateSource = async () => {
|
const handleUpdateSource = async () => {
|
||||||
if (!viewingSource) return
|
if (!viewingSource) return
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/v1/datasources/${viewingSource.id}/trigger`)
|
await triggerDatasource(viewingSource.id)
|
||||||
messageApi.success('已触发更新')
|
messageApi.success('已触发更新')
|
||||||
setViewDrawerVisible(false)
|
setViewDrawerVisible(false)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as { response?: { data?: { detail?: string } } }
|
const err = error as { response?: { status?: number; data?: { detail?: string | {
|
||||||
messageApi.error(err.response?.data?.detail || '更新失败')
|
reason?: string
|
||||||
|
message?: string
|
||||||
|
progress?: number | null
|
||||||
|
phase?: string | null
|
||||||
|
records_processed?: number | null
|
||||||
|
total_records?: number | null
|
||||||
|
} } } }
|
||||||
|
const detail = err.response?.data?.detail
|
||||||
|
if (err.response?.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') {
|
||||||
|
confirmForceTrigger(viewingSource.id, detail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messageApi.error(typeof detail === 'string' ? detail : detail?.message || '更新失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
293
planet.sh
293
planet.sh
@@ -5,11 +5,30 @@ set -e
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
RED='\033[0;31m'
|
RED='\033[38;5;203m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[38;5;114m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[38;5;221m'
|
||||||
BLUE='\033[0;34m'
|
BLUE='\033[38;5;111m'
|
||||||
|
CYAN='\033[38;5;117m'
|
||||||
|
WHITE='\033[1;37m'
|
||||||
|
DIM='\033[2m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
WAIT_SPINNER_FRAMES=(
|
||||||
|
'⠉⠁'
|
||||||
|
'⠈⠉'
|
||||||
|
'⠀⠙'
|
||||||
|
'⠀⠸'
|
||||||
|
'⠀⢰'
|
||||||
|
'⠀⣠'
|
||||||
|
'⢀⣀'
|
||||||
|
'⣀⡀'
|
||||||
|
'⣄⠀'
|
||||||
|
'⡆⠀'
|
||||||
|
'⠇⠀'
|
||||||
|
'⠋⠀'
|
||||||
|
)
|
||||||
|
WAIT_SPINNER_STEP=0
|
||||||
|
WAIT_SPINNER_TICKS_PER_SECOND=8
|
||||||
|
|
||||||
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
|
BACKEND_MAX_RETRIES="${BACKEND_MAX_RETRIES:-3}"
|
||||||
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}"
|
BACKEND_HEALTH_CHECK_ATTEMPTS="${BACKEND_HEALTH_CHECK_ATTEMPTS:-10}"
|
||||||
@@ -37,6 +56,100 @@ compose_up() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
render_wait_spinner() {
|
||||||
|
local message="$1"
|
||||||
|
local step="${2:-1}"
|
||||||
|
local frame_index=$(( (step - 1) % ${#WAIT_SPINNER_FRAMES[@]} ))
|
||||||
|
local frame="${WAIT_SPINNER_FRAMES[$frame_index]}"
|
||||||
|
printf "\r${DIM}·${NC} ${CYAN}%s${NC} ${WHITE}%s${NC}\033[K" "$frame" "$message"
|
||||||
|
}
|
||||||
|
|
||||||
|
animate_wait_spinner() {
|
||||||
|
local message="$1"
|
||||||
|
local duration="${2:-1}"
|
||||||
|
local ticks=$(( duration * WAIT_SPINNER_TICKS_PER_SECOND ))
|
||||||
|
local tick=0
|
||||||
|
|
||||||
|
if [ "$ticks" -lt 1 ]; then
|
||||||
|
ticks=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
while [ "$tick" -lt "$ticks" ]; do
|
||||||
|
WAIT_SPINNER_STEP=$((WAIT_SPINNER_STEP + 1))
|
||||||
|
render_wait_spinner "$message" "$WAIT_SPINNER_STEP"
|
||||||
|
sleep 0.125
|
||||||
|
tick=$((tick + 1))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_wait_spinner() {
|
||||||
|
printf "\r\033[K"
|
||||||
|
}
|
||||||
|
|
||||||
|
finish_wait_spinner() {
|
||||||
|
log_line "done" "$GREEN" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_line() {
|
||||||
|
local label="$1"
|
||||||
|
local color="$2"
|
||||||
|
local message="$3"
|
||||||
|
clear_wait_spinner
|
||||||
|
printf "${DIM}·${NC} ${color}%-4s${NC} ${WHITE}%s${NC}\n" "$label" "$message"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_step() {
|
||||||
|
log_line "run" "$BLUE" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
log_line "warn" "$YELLOW" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
log_line "fail" "$RED" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_note() {
|
||||||
|
printf "${DIM} %s${NC}\n" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
log_line "done" "$GREEN" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_splash() {
|
||||||
|
clear_wait_spinner
|
||||||
|
printf "%b" "$CYAN"
|
||||||
|
cat <<'EOF'
|
||||||
|
___ _ _ _ _ _
|
||||||
|
|_ _|_ __ | |_ ___| | (_) __ _ ___ _ __ | |_
|
||||||
|
| || '_ \| __/ _ \ | | |/ _` |/ _ \ '_ \| __|
|
||||||
|
| || | | | || __/ | | | (_| | __/ | | | |_
|
||||||
|
|___|_| |_|\__\___|_|_|_|\__, |\___|_| |_|\__|
|
||||||
|
|___/
|
||||||
|
EOF
|
||||||
|
printf "%b\n" "$NC"
|
||||||
|
printf "%b" "$BLUE"
|
||||||
|
cat <<'EOF'
|
||||||
|
____ _ _
|
||||||
|
| _ \| | __ _ _ __ ___ | |_
|
||||||
|
| |_) | |/ _` | '_ \ / _ \| __|
|
||||||
|
| __/| | (_| | | | | __/| |_
|
||||||
|
|_| |_|\__,_|_| |_|\___| \__|
|
||||||
|
EOF
|
||||||
|
printf "%b\n" "$NC"
|
||||||
|
printf "%b" "$WHITE"
|
||||||
|
cat <<'EOF'
|
||||||
|
____ _
|
||||||
|
| _ \| | __ _ _ __
|
||||||
|
| |_) | |/ _` | '_ \
|
||||||
|
| __/| | (_| | | | |
|
||||||
|
|_| |_|\__,_|_| |_|
|
||||||
|
EOF
|
||||||
|
printf "%b\n\n" "$NC"
|
||||||
|
}
|
||||||
|
|
||||||
run_with_retry() {
|
run_with_retry() {
|
||||||
local max_retries="$1"
|
local max_retries="$1"
|
||||||
local interval="$2"
|
local interval="$2"
|
||||||
@@ -52,12 +165,12 @@ run_with_retry() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$attempt" -eq "$max_retries" ]; then
|
if [ "$attempt" -eq "$max_retries" ]; then
|
||||||
echo -e "${RED}❌ ${failure_message}${NC}"
|
clear_wait_spinner
|
||||||
|
log_error "${failure_message}"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⚠️ ${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试...${NC}"
|
animate_wait_spinner "${retry_label} 第 ${attempt}/${max_retries} 次失败,${interval} 秒后重试" "$interval"
|
||||||
sleep "$interval"
|
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -65,17 +178,17 @@ run_with_retry() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensure_uv_backend_deps() {
|
ensure_uv_backend_deps() {
|
||||||
echo -e "${BLUE}📦 检查后端 uv 环境...${NC}"
|
log_step "检查后端 uv 环境"
|
||||||
|
|
||||||
if ! command -v uv >/dev/null 2>&1; then
|
if ! command -v uv >/dev/null 2>&1; then
|
||||||
echo -e "${RED}❌ 未找到 uv,请先安装 uv 并加入 PATH${NC}"
|
log_error "未找到 uv,请先安装 uv 并加入 PATH"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$SCRIPT_DIR"
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||||||
echo -e "${YELLOW}⚠️ 未检测到 .venv,正在执行 uv sync...${NC}"
|
log_warn "未检测到 .venv,正在执行 uv sync"
|
||||||
if ! run_with_retry \
|
if ! run_with_retry \
|
||||||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||||||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||||||
@@ -87,23 +200,23 @@ ensure_uv_backend_deps() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then
|
||||||
echo -e "${RED}❌ uv 环境初始化失败,未找到 .venv/bin/python${NC}"
|
log_error "uv 环境初始化失败,未找到 .venv/bin/python"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_frontend_deps() {
|
ensure_frontend_deps() {
|
||||||
echo -e "${BLUE}📦 检查前端依赖...${NC}"
|
log_step "检查前端依赖"
|
||||||
|
|
||||||
if ! command -v bun >/dev/null 2>&1; then
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
echo -e "${RED}❌ 未找到 bun,请先安装或加载 bun 到 PATH${NC}"
|
log_error "未找到 bun,请先安装或加载 bun 到 PATH"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$SCRIPT_DIR/frontend"
|
cd "$SCRIPT_DIR/frontend"
|
||||||
|
|
||||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
||||||
echo -e "${YELLOW}⚠️ 前端依赖缺失,正在执行 bun install...${NC}"
|
log_warn "前端依赖缺失,正在执行 bun install"
|
||||||
if ! run_with_retry \
|
if ! run_with_retry \
|
||||||
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
"$DEPENDENCY_INSTALL_MAX_RETRIES" \
|
||||||
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
"$DEPENDENCY_INSTALL_RETRY_INTERVAL" \
|
||||||
@@ -115,7 +228,7 @@ ensure_frontend_deps() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then
|
||||||
echo -e "${RED}❌ 前端依赖安装失败,未找到 vite${NC}"
|
log_error "前端依赖安装失败,未找到 vite"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -129,14 +242,15 @@ wait_for_http() {
|
|||||||
|
|
||||||
while [ "$attempt" -le "$attempts" ]; do
|
while [ "$attempt" -le "$attempts" ]; do
|
||||||
if curl -s "$url" > /dev/null 2>&1; then
|
if curl -s "$url" > /dev/null 2>&1; then
|
||||||
|
finish_wait_spinner "${service_name}已就绪"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⏳ 等待${service_name}就绪...${NC}"
|
animate_wait_spinner "等待${service_name}就绪" "$interval"
|
||||||
sleep "$interval"
|
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
clear_wait_spinner
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,14 +266,15 @@ wait_for_container_health() {
|
|||||||
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)"
|
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || true)"
|
||||||
|
|
||||||
if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then
|
if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then
|
||||||
|
finish_wait_spinner "${service_name}容器健康检查已通过"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⏳ 等待${service_name}容器健康检查通过...${NC}"
|
animate_wait_spinner "等待${service_name}容器健康检查通过" "$interval"
|
||||||
sleep "$interval"
|
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
clear_wait_spinner
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,15 +288,15 @@ wait_for_postgres_health() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start_database_services() {
|
start_database_services() {
|
||||||
docker start planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis
|
docker start planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
restart_database_services() {
|
restart_database_services() {
|
||||||
docker restart planet_postgres planet_redis 2>/dev/null || compose_up up -d postgres redis
|
docker restart planet_postgres planet_redis >/dev/null 2>&1 || compose_up up -d postgres redis >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
start_postgres_service() {
|
start_postgres_service() {
|
||||||
docker start planet_postgres 2>/dev/null || compose_up up -d postgres
|
docker start planet_postgres >/dev/null 2>&1 || compose_up up -d postgres >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
start_backend_with_retry() {
|
start_backend_with_retry() {
|
||||||
@@ -198,11 +313,12 @@ start_backend_with_retry() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⚠️ 后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
|
|
||||||
kill "$BACKEND_PID" 2>/dev/null || true
|
kill "$BACKEND_PID" 2>/dev/null || true
|
||||||
|
animate_wait_spinner "后端第 ${retry}/${BACKEND_MAX_RETRIES} 次启动未就绪,准备重试" "$BACKEND_HEALTH_CHECK_INTERVAL"
|
||||||
retry=$((retry + 1))
|
retry=$((retry + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
clear_wait_spinner
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,10 +326,10 @@ start_ai_provider_service() {
|
|||||||
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
local ai_provider_port="${1:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||||
local retry=1
|
local retry=1
|
||||||
|
|
||||||
echo -e "${BLUE}🧠 启动 AI Provider...${NC}"
|
log_step "启动 AI Provider"
|
||||||
|
|
||||||
while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do
|
while [ "$retry" -le "$AI_PROVIDER_START_MAX_RETRIES" ]; do
|
||||||
if docker start planet_aiprovider 2>/dev/null || compose_up up -d aiprovider; then
|
if docker start planet_aiprovider >/dev/null 2>&1 || compose_up up -d aiprovider >/dev/null 2>&1; then
|
||||||
if wait_for_container_health "planet_aiprovider" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
|
if wait_for_container_health "planet_aiprovider" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider" &&
|
||||||
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
wait_for_http "http://localhost:${ai_provider_port}/health" "$AI_PROVIDER_HEALTH_CHECK_ATTEMPTS" "$AI_PROVIDER_HEALTH_CHECK_INTERVAL" "AI Provider"; then
|
||||||
return 0
|
return 0
|
||||||
@@ -221,18 +337,19 @@ start_ai_provider_service() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then
|
if [ "$retry" -eq "$AI_PROVIDER_START_MAX_RETRIES" ]; then
|
||||||
echo -e "${RED}❌ AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次${NC}"
|
clear_wait_spinner
|
||||||
|
log_error "AI Provider 启动失败,已重试 ${AI_PROVIDER_START_MAX_RETRIES} 次"
|
||||||
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
|
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⚠️ AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}"
|
docker restart planet_aiprovider >/dev/null 2>&1 || true
|
||||||
docker restart planet_aiprovider 2>/dev/null || true
|
animate_wait_spinner "AI Provider 第 ${retry}/${AI_PROVIDER_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$AI_PROVIDER_RETRY_INTERVAL"
|
||||||
sleep "$AI_PROVIDER_RETRY_INTERVAL"
|
|
||||||
retry=$((retry + 1))
|
retry=$((retry + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
echo -e "${RED}❌ AI Provider 启动失败${NC}"
|
clear_wait_spinner
|
||||||
|
log_error "AI Provider 启动失败"
|
||||||
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
|
docker logs --tail 20 planet_aiprovider 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -252,11 +369,12 @@ start_frontend_with_retry() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⚠️ 前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试...${NC}"
|
|
||||||
kill "$FRONTEND_PID" 2>/dev/null || true
|
kill "$FRONTEND_PID" 2>/dev/null || true
|
||||||
|
animate_wait_spinner "前端第 ${retry}/${FRONTEND_MAX_RETRIES} 次启动未就绪,准备重试" "$FRONTEND_HEALTH_CHECK_INTERVAL"
|
||||||
retry=$((retry + 1))
|
retry=$((retry + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
clear_wait_spinner
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,15 +387,15 @@ ensure_database_services_healthy() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||||||
echo -e "${RED}❌ 数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}"
|
clear_wait_spinner
|
||||||
|
log_error "数据库启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||||||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||||||
docker logs --tail 20 planet_redis 2>/dev/null || true
|
docker logs --tail 20 planet_redis 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}"
|
docker restart planet_postgres planet_redis >/dev/null 2>&1 || true
|
||||||
docker restart planet_postgres planet_redis 2>/dev/null || true
|
animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
|
||||||
sleep "$DATABASE_RETRY_INTERVAL"
|
|
||||||
retry=$((retry + 1))
|
retry=$((retry + 1))
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -291,14 +409,14 @@ ensure_postgres_service_healthy() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||||||
echo -e "${RED}❌ PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}"
|
clear_wait_spinner
|
||||||
|
log_error "PostgreSQL 启动失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||||||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⚠️ PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器...${NC}"
|
docker restart planet_postgres >/dev/null 2>&1 || true
|
||||||
docker restart planet_postgres 2>/dev/null || true
|
animate_wait_spinner "PostgreSQL 第 ${retry}/${DATABASE_START_MAX_RETRIES} 次启动后仍未健康,正在重启容器" "$DATABASE_RETRY_INTERVAL"
|
||||||
sleep "$DATABASE_RETRY_INTERVAL"
|
|
||||||
retry=$((retry + 1))
|
retry=$((retry + 1))
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -306,7 +424,7 @@ ensure_postgres_service_healthy() {
|
|||||||
restart_database_service() {
|
restart_database_service() {
|
||||||
local retry=1
|
local retry=1
|
||||||
|
|
||||||
echo -e "${BLUE}🗄️ 重启数据库...${NC}"
|
log_step "重启数据库"
|
||||||
|
|
||||||
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
while [ "$retry" -le "$DATABASE_START_MAX_RETRIES" ]; do
|
||||||
if restart_database_services && wait_for_database_health; then
|
if restart_database_services && wait_for_database_health; then
|
||||||
@@ -315,14 +433,14 @@ restart_database_service() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
if [ "$retry" -eq "$DATABASE_START_MAX_RETRIES" ]; then
|
||||||
echo -e "${RED}❌ 数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次${NC}"
|
clear_wait_spinner
|
||||||
|
log_error "数据库重启失败,已重试 ${DATABASE_START_MAX_RETRIES} 次"
|
||||||
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
docker logs --tail 20 planet_postgres 2>/dev/null || true
|
||||||
docker logs --tail 20 planet_redis 2>/dev/null || true
|
docker logs --tail 20 planet_redis 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}⚠️ 数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试...${NC}"
|
animate_wait_spinner "数据库第 ${retry}/${DATABASE_START_MAX_RETRIES} 次重启后仍未健康,继续重试" "$DATABASE_RETRY_INTERVAL"
|
||||||
sleep "$DATABASE_RETRY_INTERVAL"
|
|
||||||
retry=$((retry + 1))
|
retry=$((retry + 1))
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -332,7 +450,7 @@ start_backend_service() {
|
|||||||
local backend_port_requested="$2"
|
local backend_port_requested="$2"
|
||||||
local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
|
local ai_provider_port="${3:-$DEFAULT_AI_PROVIDER_PORT}"
|
||||||
|
|
||||||
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
|
log_step "启动数据库"
|
||||||
ensure_database_services_healthy
|
ensure_database_services_healthy
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
@@ -342,10 +460,10 @@ start_backend_service() {
|
|||||||
kill_port_if_requested "$backend_port" "后端"
|
kill_port_if_requested "$backend_port" "后端"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${BLUE}🔧 启动后端...${NC}"
|
log_step "启动后端"
|
||||||
ensure_uv_backend_deps
|
ensure_uv_backend_deps
|
||||||
if ! start_backend_with_retry "$backend_port"; then
|
if ! start_backend_with_retry "$backend_port"; then
|
||||||
echo -e "${RED}❌ 后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次${NC}"
|
log_error "后端启动失败,已重试 ${BACKEND_MAX_RETRIES} 次"
|
||||||
tail -10 /tmp/planet_backend.log
|
tail -10 /tmp/planet_backend.log
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -359,10 +477,10 @@ start_frontend_service() {
|
|||||||
kill_port_if_requested "$frontend_port" "前端"
|
kill_port_if_requested "$frontend_port" "前端"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${BLUE}🌐 启动前端...${NC}"
|
log_step "启动前端"
|
||||||
ensure_frontend_deps
|
ensure_frontend_deps
|
||||||
if ! start_frontend_with_retry "$frontend_port"; then
|
if ! start_frontend_with_retry "$frontend_port"; then
|
||||||
echo -e "${RED}❌ 前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次${NC}"
|
log_error "前端启动失败,已重试 ${FRONTEND_MAX_RETRIES} 次"
|
||||||
tail -10 /tmp/planet_frontend.log
|
tail -10 /tmp/planet_frontend.log
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -372,7 +490,7 @@ validate_port() {
|
|||||||
local port="$1"
|
local port="$1"
|
||||||
|
|
||||||
if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
if ! [[ "$port" =~ ^[0-9]+$ ]] || [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
|
||||||
echo -e "${RED}❌ 非法端口: ${port}${NC}"
|
log_error "非法端口: ${port}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -381,10 +499,10 @@ kill_port_if_requested() {
|
|||||||
local port="$1"
|
local port="$1"
|
||||||
local service_name="$2"
|
local service_name="$2"
|
||||||
|
|
||||||
echo -e "${YELLOW}🧹 检测 ${service_name} 端口 ${port} 占用...${NC}"
|
log_warn "检测 ${service_name} 端口 ${port} 占用"
|
||||||
|
|
||||||
if command -v fuser >/dev/null 2>&1 && fuser "${port}/tcp" >/dev/null 2>&1; then
|
if command -v fuser >/dev/null 2>&1 && fuser "${port}/tcp" >/dev/null 2>&1; then
|
||||||
echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}"
|
log_step "发现端口 ${port} 占用,正在终止"
|
||||||
fuser -k "${port}/tcp" >/dev/null 2>&1 || true
|
fuser -k "${port}/tcp" >/dev/null 2>&1 || true
|
||||||
sleep 1
|
sleep 1
|
||||||
return 0
|
return 0
|
||||||
@@ -394,14 +512,14 @@ kill_port_if_requested() {
|
|||||||
local pids
|
local pids
|
||||||
pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)"
|
pids="$(lsof -ti tcp:"${port}" 2>/dev/null || true)"
|
||||||
if [ -n "$pids" ]; then
|
if [ -n "$pids" ]; then
|
||||||
echo -e "${BLUE}🔌 发现端口 ${port} 占用,正在终止...${NC}"
|
log_step "发现端口 ${port} 占用,正在终止"
|
||||||
kill $pids 2>/dev/null || true
|
kill $pids 2>/dev/null || true
|
||||||
sleep 1
|
sleep 1
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}✅ 端口 ${port} 未被占用${NC}"
|
log_success "端口 ${port} 未被占用"
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_service_args() {
|
parse_service_args() {
|
||||||
@@ -447,7 +565,7 @@ parse_service_args() {
|
|||||||
shift 1
|
shift 1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo -e "${RED}❌ 未知参数: $1${NC}"
|
log_error "未知参数: $1"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -462,9 +580,9 @@ cleanup_exit_containers() {
|
|||||||
local exit_containers
|
local exit_containers
|
||||||
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
|
exit_containers="$(docker ps -a --filter status=exited -q 2>/dev/null || true)"
|
||||||
if [ -n "$exit_containers" ]; then
|
if [ -n "$exit_containers" ]; then
|
||||||
echo -e "${BLUE}🗑️ 清理残留 Exit 容器...${NC}"
|
log_step "清理残留 Exit 容器"
|
||||||
echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
echo "$exit_containers" | xargs -r docker rm -f >/dev/null 2>&1 || true
|
||||||
echo -e "${GREEN}✅ 残留容器已清理${NC}"
|
log_success "残留容器已清理"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,15 +610,15 @@ create_user() {
|
|||||||
|
|
||||||
ensure_uv_backend_deps
|
ensure_uv_backend_deps
|
||||||
|
|
||||||
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
|
log_step "启动数据库"
|
||||||
ensure_postgres_service_healthy
|
ensure_postgres_service_healthy
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
echo -e "${BLUE}👤 创建用户${NC}"
|
log_step "创建用户"
|
||||||
read -r -p "用户名: " username
|
read -r -p "用户名: " username
|
||||||
|
|
||||||
if [ -z "$username" ]; then
|
if [ -z "$username" ]; then
|
||||||
echo -e "${RED}❌ 用户名不能为空${NC}"
|
log_error "用户名不能为空"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -516,17 +634,17 @@ create_user() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [ -z "$password" ]; then
|
if [ -z "$password" ]; then
|
||||||
echo -e "${RED}❌ 密码不能为空${NC}"
|
log_error "密码不能为空"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${#password}" -lt 8 ]; then
|
if [ "${#password}" -lt 8 ]; then
|
||||||
echo -e "${RED}❌ 密码长度不能少于 8 位${NC}"
|
log_error "密码长度不能少于 8 位"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$password" != "$password_confirm" ]; then
|
if [ "$password" != "$password_confirm" ]; then
|
||||||
echo -e "${RED}❌ 两次输入的密码不一致${NC}"
|
log_error "两次输入的密码不一致"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -580,35 +698,36 @@ async def main():
|
|||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
PY
|
PY
|
||||||
|
|
||||||
echo -e "${GREEN}✅ 用户创建成功${NC}"
|
log_success "用户创建成功"
|
||||||
echo " 用户名: ${username}"
|
log_note "用户名: ${username}"
|
||||||
echo " 角色: ${role}"
|
log_note "角色: ${role}"
|
||||||
echo " 邮箱: ${email}"
|
log_note "邮箱: ${email}"
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
parse_service_args "$@"
|
parse_service_args "$@"
|
||||||
cleanup_exit_containers
|
cleanup_exit_containers
|
||||||
|
|
||||||
echo -e "${BLUE}🚀 启动智能星球计划...${NC}"
|
print_splash
|
||||||
|
log_step "启动智能星球计划"
|
||||||
|
|
||||||
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
|
start_backend_service "$BACKEND_PORT" "$BACKEND_PORT_REQUESTED" "$AI_PROVIDER_PORT"
|
||||||
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
|
start_frontend_service "$FRONTEND_PORT" "$FRONTEND_PORT_REQUESTED"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}✅ 启动完成!${NC}"
|
log_success "启动完成"
|
||||||
echo " 前端: http://localhost:${FRONTEND_PORT}"
|
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
||||||
echo " 后端: http://localhost:${BACKEND_PORT}"
|
log_note "后端: http://localhost:${BACKEND_PORT}"
|
||||||
echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}"
|
log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}"
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
echo -e "${YELLOW}🛑 停止服务...${NC}"
|
log_warn "停止服务"
|
||||||
stop_backend_service
|
stop_backend_service
|
||||||
stop_ai_provider_service
|
stop_ai_provider_service
|
||||||
stop_frontend_service
|
stop_frontend_service
|
||||||
docker stop planet_postgres planet_redis 2>/dev/null || true
|
docker stop planet_postgres planet_redis 2>/dev/null || true
|
||||||
echo -e "${GREEN}✅ 已停止${NC}"
|
log_success "已停止"
|
||||||
}
|
}
|
||||||
|
|
||||||
restart() {
|
restart() {
|
||||||
@@ -622,7 +741,7 @@ restart() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${YELLOW}🔄 按需重启服务...${NC}"
|
log_warn "按需重启服务"
|
||||||
|
|
||||||
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||||||
restart_database_service
|
restart_database_service
|
||||||
@@ -647,43 +766,43 @@ restart() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${GREEN}✅ 重启完成!${NC}"
|
log_success "重启完成"
|
||||||
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
if [ "$DATABASE_REQUESTED" -eq 1 ]; then
|
||||||
echo " 数据库: planet_postgres, planet_redis"
|
log_note "数据库: planet_postgres, planet_redis"
|
||||||
fi
|
fi
|
||||||
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
|
if [ "$AI_PROVIDER_REQUESTED" -eq 1 ]; then
|
||||||
echo " AI Provider: http://localhost:${AI_PROVIDER_PORT}"
|
log_note "AI Provider: http://localhost:${AI_PROVIDER_PORT}"
|
||||||
fi
|
fi
|
||||||
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
if [ "$BACKEND_PORT_REQUESTED" -eq 1 ]; then
|
||||||
echo " 后端: http://localhost:${BACKEND_PORT}"
|
log_note "后端: http://localhost:${BACKEND_PORT}"
|
||||||
fi
|
fi
|
||||||
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
if [ "$FRONTEND_PORT_REQUESTED" -eq 1 ]; then
|
||||||
echo " 前端: http://localhost:${FRONTEND_PORT}"
|
log_note "前端: http://localhost:${FRONTEND_PORT}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
health() {
|
health() {
|
||||||
echo "📊 容器状态:"
|
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}容器状态${NC}"
|
||||||
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔍 服务状态:"
|
echo -e "${DIM}·${NC} ${BLUE}view${NC} ${WHITE}服务状态${NC}"
|
||||||
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
||||||
echo -e " 后端: ${GREEN}✅ 运行中${NC}"
|
echo -e "${DIM} 后端:${NC} ${GREEN}online${NC}"
|
||||||
else
|
else
|
||||||
echo -e " 后端: ${RED}❌ 未运行${NC}"
|
echo -e "${DIM} 后端:${NC} ${RED}offline${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then
|
if curl -s "http://localhost:${DEFAULT_AI_PROVIDER_PORT}/health" > /dev/null 2>&1; then
|
||||||
echo -e " AI Provider: ${GREEN}✅ 运行中${NC}"
|
echo -e "${DIM} AI Provider:${NC} ${GREEN}online${NC}"
|
||||||
else
|
else
|
||||||
echo -e " AI Provider: ${RED}❌ 未运行${NC}"
|
echo -e "${DIM} AI Provider:${NC} ${RED}offline${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if curl -s http://localhost:3000 > /dev/null 2>&1; then
|
if curl -s http://localhost:3000 > /dev/null 2>&1; then
|
||||||
echo -e " 前端: ${GREEN}✅ 运行中${NC}"
|
echo -e "${DIM} 前端:${NC} ${GREEN}online${NC}"
|
||||||
else
|
else
|
||||||
echo -e " 前端: ${RED}❌ 未运行${NC}"
|
echo -e "${DIM} 前端:${NC} ${RED}offline${NC}"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user