release: bump version to 0.52.0

This commit is contained in:
linkong
2026-05-12 17:15:02 +08:00
parent b15d097b9c
commit b87cb310fd
70 changed files with 5589 additions and 2187 deletions

View File

@@ -46,6 +46,16 @@ rg -n "class |def |function |export |router|@router|interface |type " <path>
- Keep filenames lowercase and hyphenated.
- Apply the repository-specific rules file before writing.
#### Document Audience Routing (Planet)
In this repository, classify the action's performer before picking a target file:
- Browser/UI end user → `docs/technical/{zh,en}/manual.md` or `quickstart.md`.
- Shell / Docker / log paths / `planet.sh` / SMTP fallbacks / port forwarding → `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`).
- Second-party developers → existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
Never put shell commands, log paths, or Docker operations into `manual.md` / `quickstart.md`. Never put UI button labels or screenshots into `ops-*.md`. When the same action has both a UI and a CLI path, write each in its own home and cross-link them with one sentence.
For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly.
### Step 3 — Write

View File

@@ -1 +1 @@
0.51.1
0.52.0

View File

@@ -8,12 +8,14 @@ from app.api.v1 import (
docs,
tasks,
dashboard,
websocket,
alerts,
settings,
collected_data,
data_products,
layers,
visualization,
vessel_aggregation,
vessels,
bgp,
news,
system_control,
@@ -36,12 +38,15 @@ api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboar
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(system_control.router, prefix="/system", tags=["system"])
api_router.include_router(data_products.router, prefix="/data-products", tags=["data-products"])
api_router.include_router(layers.router, prefix="/layers", tags=["layers"])
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
api_router.include_router(
vessel_aggregation.router,
prefix="/vessel-aggregation",
tags=["vessel-aggregation"],
)
api_router.include_router(vessels.router, prefix="/vessels", tags=["vessels"])
api_router.include_router(bgp.router, prefix="/bgp", tags=["bgp"])
api_router.include_router(tv.router, prefix="/tv", tags=["tv"])
api_router.include_router(news.router, prefix="/news", tags=["news"])

View File

@@ -1,26 +1,85 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from app.core.config import settings
from app.core.logging import get_logger
from app.core.security import (
create_access_token,
create_refresh_token,
blacklist_token,
get_current_user,
get_password_hash,
verify_password,
)
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import Token
from app.schemas.user import UserCreate, UserResponse
from app.schemas.user import (
ForgotPasswordRequest,
ResendCodeRequest,
ResetPasswordRequest,
UserRegister,
UserResponse,
VerifyEmailRequest,
)
from app.services import otp
from app.services.email import (
EmailError,
EmailNotConfiguredError,
send_verification_email,
)
logger = get_logger(__name__)
router = APIRouter()
def _token_response(user: User) -> dict:
access_token = create_access_token(data={"sub": user.id})
refresh = create_refresh_token(data={"sub": user.id})
expires_in = (
settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0
else None
)
return {
"access_token": access_token,
"token_type": "bearer",
"expires_in": expires_in,
"refresh_token": refresh,
"user": {
"id": user.id,
"username": user.username,
"role": user.role,
"gatekeeper_groups": user.gatekeeper_groups or [],
},
}
async def _load_user_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(
text(
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
"FROM users WHERE email = :email"
),
{"email": email},
)
row = result.fetchone()
if row is None:
return None
user = User()
user.id = row[0]
user.username = row[1]
user.email = row[2]
user.password_hash = row[3]
user.role = row[4]
user.is_active = row[5]
user.gatekeeper_groups = row[6] or []
user.email_verified = bool(row[7])
return user
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
@@ -28,7 +87,8 @@ async def login(
):
result = await db.execute(
text(
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups FROM users WHERE username = :username"
"SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified "
"FROM users WHERE username = :username"
),
{"username": form_data.username},
)
@@ -47,6 +107,7 @@ async def login(
user.role = row[4]
user.is_active = row[5]
user.gatekeeper_groups = row[6] or []
user.email_verified = bool(row[7])
if not verify_password(form_data.password, user.password_hash):
raise HTTPException(
@@ -58,25 +119,13 @@ async def login(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is inactive",
)
if not user.email_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "EMAIL_NOT_VERIFIED", "email": user.email},
)
access_token = create_access_token(data={"sub": user.id})
refresh_token = create_refresh_token(data={"sub": user.id})
expires_in = None
if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0:
expires_in = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
return {
"access_token": access_token,
"token_type": "bearer",
"expires_in": expires_in,
"user": {
"id": user.id,
"username": user.username,
"role": user.role,
"gatekeeper_groups": user.gatekeeper_groups or [],
},
}
return _token_response(user)
@router.post("/refresh", response_model=Token)
@@ -116,5 +165,179 @@ async def get_me(current_user: User = Depends(get_current_user)):
"role": current_user.role,
"gatekeeper_groups": current_user.gatekeeper_groups or [],
"is_active": current_user.is_active,
"email_verified": getattr(current_user, "email_verified", True),
"created_at": current_user.created_at,
}
async def _send_code_or_raise(db: AsyncSession, email: str, code: str, purpose: str) -> None:
try:
await send_verification_email(db, to=email, code=code, purpose=purpose)
except EmailNotConfiguredError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"code": exc.code, "message": str(exc)},
) from exc
except EmailError as exc:
logger.warning_event(
"SMTP send failed",
event="auth.email.send_failed",
context={"email": email, "purpose": purpose, "error": str(exc)},
)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail={"code": exc.code, "message": str(exc)},
) from exc
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)):
existing = await db.execute(
text("SELECT id, email_verified FROM users WHERE username = :u OR email = :e"),
{"u": payload.username, "e": payload.email},
)
row = existing.fetchone()
if row is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "USER_ALREADY_EXISTS", "message": "Username or email already in use"},
)
user = User(
username=payload.username,
email=payload.email,
password_hash=get_password_hash(payload.password),
role="viewer",
is_active=True,
email_verified=False,
)
db.add(user)
await db.commit()
try:
code = otp.issue_code(payload.email, "register")
except otp.OtpResendRateLimited as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
) from exc
await _send_code_or_raise(db, payload.email, code, "register")
return {"status": "pending_verification", "email": payload.email}
@router.post("/verify-email", response_model=Token)
async def verify_email(payload: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
user = await _load_user_by_email(db, payload.email)
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "USER_NOT_FOUND"},
)
try:
otp.verify_code(payload.email, "register", payload.code)
except otp.OtpExpired as exc:
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail={"code": exc.code, "message": str(exc)},
) from exc
except otp.OtpAttemptsExceeded as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={"code": exc.code, "message": str(exc)},
) from exc
except otp.OtpInvalid as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": exc.code, "message": str(exc)},
) from exc
await db.execute(
text("UPDATE users SET email_verified = TRUE WHERE id = :id"),
{"id": user.id},
)
await db.commit()
user.email_verified = True
return _token_response(user)
@router.post("/resend-code")
async def resend_code(payload: ResendCodeRequest, db: AsyncSession = Depends(get_db)):
user = await _load_user_by_email(db, payload.email)
if user is None:
# Avoid email enumeration; pretend success.
return {"status": "ok"}
if payload.purpose == "register" and user.email_verified:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "ALREADY_VERIFIED"},
)
try:
code = otp.issue_code(payload.email, payload.purpose)
except otp.OtpResendRateLimited as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds},
) from exc
await _send_code_or_raise(db, payload.email, code, payload.purpose)
return {"status": "ok"}
@router.post("/forgot-password")
async def forgot_password(payload: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
user = await _load_user_by_email(db, payload.email)
if user is None:
# Don't leak whether an email is registered.
return {"status": "ok"}
try:
code = otp.issue_code(payload.email, "reset_password")
except otp.OtpResendRateLimited:
# Silently accept; the user can retry after the cooldown.
return {"status": "ok"}
try:
await send_verification_email(db, to=payload.email, code=code, purpose="reset_password")
except EmailNotConfiguredError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"code": exc.code, "message": str(exc)},
) from exc
except EmailError as exc:
logger.warning_event(
"SMTP send failed",
event="auth.email.send_failed",
context={"email": payload.email, "purpose": "reset_password", "error": str(exc)},
)
return {"status": "ok"}
@router.post("/reset-password")
async def reset_password(payload: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
user = await _load_user_by_email(db, payload.email)
if user is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "OTP_INVALID"},
)
try:
otp.verify_code(payload.email, "reset_password", payload.code)
except otp.OtpExpired as exc:
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail={"code": exc.code, "message": str(exc)},
) from exc
except otp.OtpAttemptsExceeded as exc:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail={"code": exc.code, "message": str(exc)},
) from exc
except otp.OtpInvalid as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": exc.code, "message": str(exc)},
) from exc
await db.execute(
text("UPDATE users SET password_hash = :p, email_verified = TRUE WHERE id = :id"),
{"p": get_password_hash(payload.new_password), "id": user.id},
)
await db.commit()
return {"status": "ok"}

View File

@@ -0,0 +1,98 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.visualization import get_visualization_geo_summary
from app.core.time import to_iso8601_utc
from app.db.session import get_db
router = APIRouter()
PRODUCT_DEFINITIONS: dict[str, dict] = {
"vessels": {
"name": "船只",
"sources": ["aisstream_vessels", "barentswatch_vessels"],
"primary_stat_key": "vessel_count",
"stat_keys": ["vessel_count", "vessel_raw_unique_mmsi", "vessel_legacy_unique_mmsi"],
},
"cables": {
"name": "海底光缆",
"sources": [
"arcgis_cables",
"arcgis_landing_points",
"arcgis_cable_landing_relation",
"telegeography_cables",
"telegeography_landing",
"telegeography_systems",
"fao_landing_points",
],
"primary_stat_key": "cable_count",
"stat_keys": ["cable_count", "landing_point_count"],
},
"satellites": {
"name": "卫星",
"sources": ["celestrak_tle", "spacetrack_tle"],
"primary_stat_key": "satellite_count",
"stat_keys": ["satellite_count"],
},
"bgp": {
"name": "BGP",
"sources": [
"ris_live_bgp",
"bgpstream_bgp",
"iptoasn_prefix_geo",
"opengeofeed_prefix_geo",
"nro_delegated_prefix_geo",
],
"primary_stat_key": "bgp_event_count",
"stat_keys": ["bgp_event_count", "bgp_incident_count", "bgp_anomaly_count", "bgp_collector_count"],
},
"compute": {
"name": "算力",
"sources": ["top500", "epoch_ai_gpu"],
"primary_stat_key": "compute_center_count",
"stat_keys": ["compute_center_count", "supercomputer_count", "gpu_cluster_count"],
},
}
def _build_product_status(product_id: str, summary: dict) -> dict:
definition = PRODUCT_DEFINITIONS[product_id]
stats = summary.get("stats", {})
product_stats = {key: stats.get(key, 0) for key in definition["stat_keys"]}
total_count = int(product_stats.get(definition["primary_stat_key"]) or 0)
return {
"product_id": product_id,
"name": definition["name"],
"sources": definition["sources"],
"generated_at": summary.get("generated_at") or to_iso8601_utc(datetime.now(UTC)),
"total_count": total_count,
"stats": product_stats,
"build_state": "ready",
"stats_scope": "global",
"stats_freshness": "cached_or_indexed",
}
@router.get("")
async def list_data_products(db: AsyncSession = Depends(get_db)):
summary = await get_visualization_geo_summary(db)
return {
"generated_at": summary.get("generated_at"),
"data": [
_build_product_status(product_id, summary)
for product_id in PRODUCT_DEFINITIONS
],
}
@router.get("/{product_id}/status")
async def get_data_product_status(
product_id: str,
db: AsyncSession = Depends(get_db),
):
if product_id not in PRODUCT_DEFINITIONS:
raise HTTPException(status_code=404, detail="Unknown data product")
summary = await get_visualization_geo_summary(db)
return _build_product_status(product_id, summary)

View File

@@ -3,7 +3,8 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select, text
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.time import to_iso8601_utc
@@ -27,6 +28,29 @@ from app.services.scheduler import (
router = APIRouter()
STALE_RUNNING_TASK_TIMEOUT_MINUTES = 90
PRODUCT_SOURCE_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("vessels", ("vessel", "ais")),
("cables", ("cable", "landing", "telegeography", "arcgis", "fao")),
("satellites", ("tle", "satellite", "spacetrack", "celestrak")),
("bgp", ("bgp", "asn", "prefix_geo", "opengeofeed", "nro")),
("compute", ("top500", "gpu", "supercomputer", "compute")),
("ai", ("huggingface", "epoch_ai")),
("media", ("news", "tv", "live_stream")),
)
class DatasourceBatchTriggerRequest(BaseModel):
source_ids: list[int] = Field(default_factory=list)
force: bool = False
module: Optional[str] = None
product: Optional[str] = None
is_active: Optional[bool] = None
priority: Optional[str] = None
run_status: Optional[str] = None
collected: Optional[bool] = None
credential_status: Optional[str] = None
q: Optional[str] = None
def format_frequency_label(minutes: int) -> str:
if minutes % 1440 == 0:
@@ -47,6 +71,20 @@ def datasource_metadata(source: str) -> dict:
}
def datasource_product_key(datasource: DataSource) -> str:
haystack = " ".join(
[
datasource.source or "",
datasource.name or "",
datasource.collector_class or "",
]
).lower()
for product, keywords in PRODUCT_SOURCE_KEYWORDS:
if any(keyword in haystack for keyword in keywords):
return product
return "other"
def is_due_for_collection(datasource: DataSource, now: datetime) -> bool:
if datasource.last_run_at is None:
return True
@@ -110,6 +148,22 @@ async def _load_latest_task_ids(
return {datasource_id: task_id for datasource_id, task_id in result.all()}
async def _load_collected_record_counts(
db: AsyncSession,
sources: list[str],
) -> dict[str, int]:
if not sources:
return {}
result = await db.execute(
select(CollectedData.source, func.count(CollectedData.id))
.where(CollectedData.source.in_(sources))
.where(CollectedData.is_current.is_(True))
.group_by(CollectedData.source)
)
return {source: int(count or 0) for source, count in result.all()}
async def _load_datasource_endpoint_overrides(
db: AsyncSession,
sources: list[str],
@@ -161,6 +215,192 @@ async def _load_datasource_list_context(
return running_tasks, endpoint_overrides
def _apply_datasource_query_filters(
query,
*,
module: Optional[str] = None,
is_active: Optional[bool] = None,
priority: Optional[str] = None,
run_status: Optional[str] = None,
q: Optional[str] = None,
) -> object:
if module:
query = query.where(DataSource.module == module)
if is_active is not None:
query = query.where(DataSource.is_active == is_active)
if priority:
query = query.where(DataSource.priority == priority)
if run_status and run_status not in {"running", "collected", "uncollected"}:
if run_status == "not_run":
query = query.where(DataSource.last_status.is_(None))
else:
query = query.where(DataSource.last_status == run_status)
if q:
like_value = f"%{q.strip()}%"
query = query.where(
or_(
DataSource.name.ilike(like_value),
DataSource.source.ilike(like_value),
DataSource.collector_class.ilike(like_value),
)
)
return query
def _filter_datasources_in_memory(
datasources: list[DataSource],
*,
running_tasks: dict[int, CollectionTask],
record_counts: dict[str, int],
product: Optional[str] = None,
run_status: Optional[str] = None,
collected: Optional[bool] = None,
credential_status: Optional[str] = None,
) -> list[DataSource]:
filtered: list[DataSource] = []
for datasource in datasources:
record_count = record_counts.get(datasource.source, 0)
if product and datasource_product_key(datasource) != product:
continue
if collected is not None and (record_count > 0) != collected:
continue
if credential_status:
metadata = datasource_metadata(datasource.source)
if metadata["credential_status"] != credential_status:
continue
if run_status == "running" and datasource.id not in running_tasks:
continue
if run_status == "collected" and record_count <= 0:
continue
if run_status == "uncollected" and record_count > 0:
continue
filtered.append(datasource)
return filtered
async def _trigger_datasource_batch(
db: AsyncSession,
datasources: list[DataSource],
*,
force: bool,
) -> dict:
if not datasources:
return {
"status": "noop",
"message": "No matching data sources to trigger",
"force": force,
"triggered": [],
"skipped": [],
"failed": [],
}
previous_task_ids: dict[int, Optional[int]] = {}
triggered_sources: list[dict] = []
skipped_sources: list[dict] = []
failed_sources: list[dict] = []
now = datetime.now(timezone.utc)
running_tasks = await _load_latest_running_tasks(
db,
[datasource.id for datasource in datasources],
)
for datasource in datasources:
if not datasource.is_active:
skipped_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "disabled",
}
)
continue
running_task = running_tasks.get(datasource.id)
if running_task is not None:
if not force:
skipped_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "already_running",
"task_id": running_task.id,
}
)
continue
cancelled = await cancel_running_collector_now(datasource.source)
if not cancelled:
await rollback_orphaned_running_task(db, datasource, running_task)
if not force and not is_due_for_collection(datasource, now):
skipped_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "within_frequency_window",
"last_run_at": to_iso8601_utc(datasource.last_run_at),
"next_run_at": to_iso8601_utc(
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
),
}
)
continue
previous_task_ids[datasource.id] = None
success = run_collector_now(datasource.source)
if not success:
failed_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "trigger_failed",
}
)
continue
triggered_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"task_id": None,
}
)
latest_task_ids = await _load_latest_task_ids(
db,
[datasource.id for datasource in datasources],
)
for datasource_id in previous_task_ids:
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
for _ in range(20):
await asyncio.sleep(0.1)
pending = [item for item in triggered_sources if item["task_id"] is None]
if not pending:
break
latest_task_ids = await _load_latest_task_ids(
db,
[item["id"] for item in pending],
)
for item in pending:
task_id = latest_task_ids.get(item["id"])
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
item["task_id"] = task_id
return {
"status": "triggered" if triggered_sources else "partial",
"message": f"Triggered {len(triggered_sources)} data sources",
"force": force,
"triggered": triggered_sources,
"skipped": skipped_sources,
"failed": failed_sources,
}
async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]:
datasource = None
try:
@@ -355,16 +595,23 @@ async def list_datasources(
module: Optional[str] = None,
is_active: Optional[bool] = None,
priority: Optional[str] = None,
product: Optional[str] = None,
run_status: Optional[str] = None,
collected: Optional[bool] = None,
credential_status: Optional[str] = None,
q: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(DataSource).order_by(DataSource.module, DataSource.id)
if module:
query = query.where(DataSource.module == module)
if is_active is not None:
query = query.where(DataSource.is_active == is_active)
if priority:
query = query.where(DataSource.priority == priority)
query = _apply_datasource_query_filters(
query,
module=module,
is_active=is_active,
priority=priority,
run_status=run_status,
q=q,
)
result = await db.execute(query)
datasources = result.scalars().all()
@@ -372,11 +619,22 @@ async def list_datasources(
collector_list = []
config = get_data_sources_config()
running_tasks, endpoint_overrides = await _load_datasource_list_context(db, datasources)
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
datasources = _filter_datasources_in_memory(
datasources,
running_tasks=running_tasks,
record_counts=record_counts,
product=product,
run_status=run_status,
collected=collected,
credential_status=credential_status,
)
for datasource in datasources:
running_task = running_tasks.get(datasource.id)
endpoint = endpoint_overrides.get(datasource.source) or config.get_yaml_url(datasource.source)
last_run_at = datasource.last_run_at
last_status = datasource.last_status
collected_records = record_counts.get(datasource.source, 0)
collector_list.append(
{
@@ -384,6 +642,7 @@ async def list_datasources(
"source": datasource.source,
"name": datasource.name,
**datasource_metadata(datasource.source),
"product": datasource_product_key(datasource),
"module": datasource.module,
"priority": datasource.priority,
"frequency": format_frequency_label(datasource.frequency_minutes),
@@ -405,6 +664,8 @@ async def list_datasources(
"phase_unit": running_task.phase_unit if running_task else None,
"records_processed": running_task.records_processed if running_task else None,
"total_records": running_task.total_records if running_task else None,
"collected_records": collected_records,
"has_collected_data": collected_records > 0,
}
)
@@ -419,110 +680,46 @@ async def trigger_all_datasources(
):
result = await db.execute(
select(DataSource)
.where(DataSource.is_active == True)
.where(DataSource.is_active.is_(True))
.order_by(DataSource.module, DataSource.id)
)
datasources = result.scalars().all()
return await _trigger_datasource_batch(db, datasources, force=force)
if not datasources:
return {
"status": "noop",
"message": "No active data sources to trigger",
"triggered": [],
"skipped": [],
"failed": [],
}
previous_task_ids: dict[int, Optional[int]] = {}
triggered_sources: list[dict] = []
skipped_sources: list[dict] = []
failed_sources: list[dict] = []
now = datetime.now(timezone.utc)
running_tasks = await _load_latest_running_tasks(
db,
[datasource.id for datasource in datasources],
)
for datasource in datasources:
running_task = running_tasks.get(datasource.id)
if running_task is not None:
skipped_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "already_running",
"task_id": running_task.id,
}
)
continue
if not force and not is_due_for_collection(datasource, now):
skipped_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "within_frequency_window",
"last_run_at": to_iso8601_utc(datasource.last_run_at),
"next_run_at": to_iso8601_utc(
datasource.last_run_at + timedelta(minutes=datasource.frequency_minutes)
),
}
)
continue
previous_task_ids[datasource.id] = None
success = run_collector_now(datasource.source)
if not success:
failed_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"reason": "trigger_failed",
}
)
continue
triggered_sources.append(
{
"id": datasource.id,
"source": datasource.source,
"name": datasource.name,
"task_id": None,
}
@router.post("/trigger-batch")
async def trigger_datasource_batch(
payload: DatasourceBatchTriggerRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query = select(DataSource).order_by(DataSource.module, DataSource.id)
if payload.source_ids:
query = query.where(DataSource.id.in_(payload.source_ids))
else:
query = _apply_datasource_query_filters(
query,
module=payload.module,
is_active=payload.is_active,
priority=payload.priority,
run_status=payload.run_status,
q=payload.q,
)
latest_task_ids = await _load_latest_task_ids(
db,
[datasource.id for datasource in datasources],
result = await db.execute(query)
datasources = result.scalars().all()
running_tasks, _ = await _load_datasource_list_context(db, datasources)
record_counts = await _load_collected_record_counts(db, [datasource.source for datasource in datasources])
datasources = _filter_datasources_in_memory(
datasources,
running_tasks=running_tasks,
record_counts=record_counts,
product=None if payload.source_ids else payload.product,
run_status=None if payload.source_ids else payload.run_status,
collected=None if payload.source_ids else payload.collected,
credential_status=None if payload.source_ids else payload.credential_status,
)
for datasource_id in previous_task_ids:
previous_task_ids[datasource_id] = latest_task_ids.get(datasource_id)
for _ in range(20):
await asyncio.sleep(0.1)
pending = [item for item in triggered_sources if item["task_id"] is None]
if not pending:
break
latest_task_ids = await _load_latest_task_ids(
db,
[item["id"] for item in pending],
)
for item in pending:
task_id = latest_task_ids.get(item["id"])
if task_id is not None and task_id != previous_task_ids.get(item["id"]):
item["task_id"] = task_id
return {
"status": "triggered" if triggered_sources else "partial",
"message": f"Triggered {len(triggered_sources)} data sources",
"force": force,
"triggered": triggered_sources,
"skipped": skipped_sources,
"failed": failed_sources,
}
return await _trigger_datasource_batch(db, datasources, force=payload.force)
@router.get("/{source_id}")

View File

@@ -0,0 +1,229 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.visualization import (
_parse_bbox,
get_bgp_anomalies_geojson,
get_bgp_collectors_geojson,
get_bgp_incidents_geojson,
get_cables_geojson,
get_landing_points_geojson,
get_satellites_geojson,
)
from app.api.v1.vessels import build_vessel_snapshot_response
from app.db.session import get_db
router = APIRouter()
DEFAULT_LAYER_LIMIT = 1000
MAX_LAYER_LIMIT = 5000
LOW_ZOOM_FEATURE_LIMIT = 500
def _clamp_limit(limit: int, zoom: int) -> tuple[int, bool]:
clamped = min(max(limit, 1), MAX_LAYER_LIMIT)
if zoom <= 3:
return min(clamped, LOW_ZOOM_FEATURE_LIMIT), clamped != limit or clamped > LOW_ZOOM_FEATURE_LIMIT
return clamped, clamped != limit
def _coordinate_in_bbox(coord: Any, bbox: tuple[float, float, float, float]) -> bool:
if not isinstance(coord, (list, tuple)) or len(coord) < 2:
return False
try:
lon = float(coord[0])
lat = float(coord[1])
except (TypeError, ValueError):
return False
lon_min, lat_min, lon_max, lat_max = bbox
return lon_min <= lon <= lon_max and lat_min <= lat <= lat_max
def _geometry_intersects_bbox(geometry: dict, bbox: tuple[float, float, float, float]) -> bool:
coordinates = geometry.get("coordinates")
geometry_type = geometry.get("type")
if geometry_type == "Point":
return _coordinate_in_bbox(coordinates, bbox)
if geometry_type in {"LineString", "MultiPoint"}:
return any(_coordinate_in_bbox(coord, bbox) for coord in coordinates or [])
if geometry_type in {"Polygon", "MultiLineString"}:
return any(
_coordinate_in_bbox(coord, bbox)
for line in coordinates or []
for coord in line
)
if geometry_type == "MultiPolygon":
return any(
_coordinate_in_bbox(coord, bbox)
for polygon in coordinates or []
for line in polygon
for coord in line
)
return False
def _guard_geojson_layer(
geojson: dict,
*,
bbox: tuple[float, float, float, float],
zoom: int,
limit: int,
) -> dict:
bounded_limit, limit_clamped = _clamp_limit(limit, zoom)
features = [
feature
for feature in geojson.get("features", [])
if _geometry_intersects_bbox(feature.get("geometry") or {}, bbox)
]
visible_count = len(features)
returned_features = features[:bounded_limit]
return {
**geojson,
"features": returned_features,
"visible_count": visible_count,
"returned_count": len(returned_features),
"diagnostics": {
"bbox_limited": True,
"limit": bounded_limit,
"limit_clamped": limit_clamped,
"truncated": visible_count > len(returned_features),
"degraded": zoom <= 3 or visible_count > len(returned_features),
"stats_scope": "viewport",
},
}
def _parse_layer_bbox(bbox: str) -> tuple[float, float, float, float]:
parsed = _parse_bbox(bbox)
if parsed is None:
raise HTTPException(status_code=400, detail="bbox is required")
return parsed
@router.get("/vessels/snapshot")
async def get_vessel_layer_snapshot(
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
zoom: int = Query(..., ge=1, le=20),
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
vessel_type: Optional[str] = Query(None, alias="type"),
since_minutes: int = Query(60, ge=1, le=1440),
db: AsyncSession = Depends(get_db),
):
parsed_bbox = _parse_layer_bbox(bbox)
return await build_vessel_snapshot_response(
db,
bbox=parsed_bbox,
zoom=zoom,
limit=limit,
vessel_type=vessel_type,
since_minutes=since_minutes,
)
@router.get("/cables")
async def get_cable_layer(
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
zoom: int = Query(..., ge=1, le=20),
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
db: AsyncSession = Depends(get_db),
):
return _guard_geojson_layer(
await get_cables_geojson(db),
bbox=_parse_layer_bbox(bbox),
zoom=zoom,
limit=limit,
)
@router.get("/landing-points")
async def get_landing_point_layer(
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
zoom: int = Query(..., ge=1, le=20),
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
db: AsyncSession = Depends(get_db),
):
return _guard_geojson_layer(
await get_landing_points_geojson(db),
bbox=_parse_layer_bbox(bbox),
zoom=zoom,
limit=limit,
)
@router.get("/satellites")
async def get_satellite_layer(
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
zoom: int = Query(..., ge=1, le=20),
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
db: AsyncSession = Depends(get_db),
):
bounded_limit, _ = _clamp_limit(limit, zoom)
return _guard_geojson_layer(
await get_satellites_geojson(limit=bounded_limit, db=db),
bbox=_parse_layer_bbox(bbox),
zoom=zoom,
limit=limit,
)
@router.get("/bgp/anomalies")
async def get_bgp_anomaly_layer(
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
zoom: int = Query(..., ge=1, le=20),
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
severity: Optional[str] = Query(None),
status: Optional[str] = Query("active"),
db: AsyncSession = Depends(get_db),
):
bounded_limit, _ = _clamp_limit(limit, zoom)
return _guard_geojson_layer(
await get_bgp_anomalies_geojson(
severity=severity,
status=status,
limit=bounded_limit,
db=db,
),
bbox=_parse_layer_bbox(bbox),
zoom=zoom,
limit=limit,
)
@router.get("/bgp/incidents")
async def get_bgp_incident_layer(
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
zoom: int = Query(..., ge=1, le=20),
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
severity: Optional[str] = Query(None),
status: Optional[str] = Query("active"),
db: AsyncSession = Depends(get_db),
):
bounded_limit, _ = _clamp_limit(limit, zoom)
return _guard_geojson_layer(
await get_bgp_incidents_geojson(
severity=severity,
status=status,
limit=min(bounded_limit, 500),
db=db,
),
bbox=_parse_layer_bbox(bbox),
zoom=zoom,
limit=limit,
)
@router.get("/bgp/collectors")
async def get_bgp_collector_layer(
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
zoom: int = Query(..., ge=1, le=20),
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
db: AsyncSession = Depends(get_db),
):
return _guard_geojson_layer(
await get_bgp_collectors_geojson(db),
bbox=_parse_layer_bbox(bbox),
zoom=zoom,
limit=limit,
)

View File

@@ -81,6 +81,17 @@ DEFAULT_SETTINGS = {
"password_policy": "medium",
},
"tv": DEFAULT_TV_SETTINGS,
"smtp": {
"host": "",
"port": 587,
"username": "",
"password": "",
"from_address": "",
"from_name": "Planet",
"use_tls": False,
"use_starttls": True,
"timeout_seconds": 20,
},
"external_integrations": {
"ai_provider": {
"service_url": "",
@@ -95,6 +106,17 @@ DEFAULT_SETTINGS = {
"default_provider": "tavily",
"providers": {},
},
"ocr": {
"enabled": False,
"provider": "paddleocr",
"base_url": "",
"api_key": "",
"model": "",
"languages": ["zh", "en"],
"timeout_seconds": 30,
"max_file_size_mb": 20,
"output_format": "markdown",
},
},
}
@@ -153,6 +175,24 @@ class TVSettingsUpdate(BaseModel):
sources: list[TVStreamSourceUpdate] = Field(default_factory=list)
class SMTPSettingsUpdate(BaseModel):
host: str = Field(default="", max_length=255)
port: int = Field(default=587, ge=1, le=65535)
username: str = Field(default="", max_length=255)
password: Optional[str] = None
clear_password: bool = False
from_address: str = Field(default="", max_length=255)
from_name: str = Field(default="Planet", max_length=120)
use_tls: bool = False
use_starttls: bool = True
timeout_seconds: int = Field(default=20, ge=3, le=300)
class SMTPTestRequest(BaseModel):
to: EmailStr
settings: Optional[SMTPSettingsUpdate] = None
class AIProviderIntegrationUpdate(BaseModel):
service_url: str = ""
service_token: Optional[str] = None
@@ -198,10 +238,23 @@ class WebSearchIntegrationUpdate(BaseModel):
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
class OCRIntegrationUpdate(BaseModel):
enabled: bool = False
provider: str = Field(default="paddleocr", max_length=80)
base_url: str = Field(default="", max_length=500)
api_key: Optional[str] = None
model: str = Field(default="", max_length=200)
languages: list[str] = Field(default_factory=lambda: ["zh", "en"])
timeout_seconds: int = Field(default=30, ge=3, le=300)
max_file_size_mb: int = Field(default=20, ge=1, le=200)
output_format: str = Field(default="markdown", pattern="^(markdown|text|json)$")
class ExternalIntegrationsUpdate(BaseModel):
ai_provider: AIProviderIntegrationUpdate
barentswatch: BarentsWatchIntegrationUpdate
web_search: WebSearchIntegrationUpdate | None = None
ocr: OCRIntegrationUpdate | None = None
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
@@ -649,6 +702,59 @@ def _runtime_config_from_web_search_payload(web_search_payload: dict) -> WebSear
)
def _normalize_ocr_payload(ocr_payload: dict | None) -> dict:
raw = dict(ocr_payload or {})
languages = raw.get("languages")
if not isinstance(languages, list) or not languages:
languages = ["zh", "en"]
return {
"enabled": bool(raw.get("enabled", False)),
"provider": str(raw.get("provider") or "paddleocr").strip().lower() or "paddleocr",
"base_url": str(raw.get("base_url") or "").strip(),
"api_key": str(raw.get("api_key") or "").strip(),
"model": str(raw.get("model") or "").strip(),
"languages": [str(item).strip() for item in languages if str(item).strip()],
"timeout_seconds": int(raw.get("timeout_seconds") or 30),
"max_file_size_mb": int(raw.get("max_file_size_mb") or 20),
"output_format": str(raw.get("output_format") or "markdown").strip() or "markdown",
}
def _resolve_ocr_api_key(ocr_config: dict) -> tuple[str, str]:
saved_key = ocr_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
return _resolve_web_search_env_secret("OCR_API_KEY")
def _build_ocr_payload(
current_payload: dict,
update: OCRIntegrationUpdate | None,
) -> dict:
current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {})
if update is None:
return current_ocr
current_key, current_key_source = _resolve_ocr_api_key(current_ocr)
current_key_preview = _mask_secret(current_key, current_key_source)["preview"]
ocr_payload = {
"enabled": update.enabled,
"provider": update.provider.strip().lower() or current_ocr.get("provider") or "paddleocr",
"base_url": update.base_url.strip(),
"model": update.model.strip(),
"languages": [item.strip() for item in update.languages if item.strip()] or ["zh", "en"],
"timeout_seconds": update.timeout_seconds,
"max_file_size_mb": update.max_file_size_mb,
"output_format": update.output_format.strip() or "markdown",
}
if not _is_secret_placeholder(update.api_key, current_key_preview):
ocr_payload["api_key"] = str(update.api_key).strip()
elif current_ocr.get("api_key"):
ocr_payload["api_key"] = current_ocr.get("api_key") or ""
else:
ocr_payload["api_key"] = ""
return ocr_payload
async def get_runtime_web_search_config(db: AsyncSession) -> WebSearchConfig:
runtime_record = await get_setting_record(db, "external_integrations")
payload = merge_with_defaults(
@@ -684,6 +790,7 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
)
normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {})
normalized_web_search = _normalize_web_search_payload(raw_payload.get("web_search") or {})
normalized_ocr = _normalize_ocr_payload(raw_payload.get("ocr") or {})
default_provider = normalized_ai["default_provider"]
providers_payload: dict[str, dict] = {}
for provider in sorted({
@@ -731,6 +838,7 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
web_search_providers_payload.get(normalized_web_search["default_provider"])
or _web_search_provider_defaults(normalized_web_search["default_provider"])
)
ocr_api_key, ocr_api_key_source = _resolve_ocr_api_key(normalized_ocr)
barentswatch_record = await get_barentswatch_config_record(db)
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
barentswatch_auth = barentswatch_auth or {}
@@ -782,6 +890,18 @@ async def serialize_external_integrations(db: AsyncSession) -> dict:
"scrape_formats": display_web_search_config.get("scrape_formats") or ["markdown"],
"source": "runtime" if runtime_setting else "env",
},
"ocr": {
"enabled": normalized_ocr["enabled"],
"provider": normalized_ocr["provider"],
"base_url": normalized_ocr["base_url"],
"api_key": _mask_secret(ocr_api_key, ocr_api_key_source),
"model": normalized_ocr["model"],
"languages": normalized_ocr["languages"],
"timeout_seconds": normalized_ocr["timeout_seconds"],
"max_file_size_mb": normalized_ocr["max_file_size_mb"],
"output_format": normalized_ocr["output_format"],
"source": "runtime" if normalized_ocr.get("api_key") else (ocr_api_key_source or "default"),
},
}
@@ -792,11 +912,12 @@ async def save_external_integrations_payload(
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
web_search_payload = _build_web_search_payload(current_payload, update.web_search)
ocr_payload = _build_ocr_payload(current_payload, update.ocr)
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": ai_payload, "web_search": web_search_payload},
{"ai_provider": ai_payload, "web_search": web_search_payload, "ocr": ocr_payload},
)
default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels")
@@ -919,6 +1040,97 @@ async def update_security_settings(
return {"status": "updated", "security": payload}
def _serialize_smtp_payload(payload: dict) -> dict:
password = str(payload.get("password") or "")
return {
"host": payload.get("host") or "",
"port": int(payload.get("port") or 587),
"username": payload.get("username") or "",
"password": _mask_secret(password, "runtime" if password else ""),
"from_address": payload.get("from_address") or "",
"from_name": payload.get("from_name") or "Planet",
"use_tls": bool(payload.get("use_tls", False)),
"use_starttls": bool(payload.get("use_starttls", True)),
"timeout_seconds": int(payload.get("timeout_seconds") or 20),
"configured": bool(payload.get("host") and payload.get("from_address")),
}
def _build_smtp_payload(current_payload: dict, update: SMTPSettingsUpdate) -> dict:
current_password = str(current_payload.get("password") or "")
current_preview = _mask_secret(current_password, "runtime" if current_password else "")["preview"]
if update.clear_password:
password = ""
elif _is_secret_placeholder(update.password, current_preview):
password = current_password
else:
password = str(update.password).strip()
return {
"host": update.host.strip(),
"port": update.port,
"username": update.username.strip(),
"password": password,
"from_address": update.from_address.strip(),
"from_name": update.from_name.strip() or "Planet",
"use_tls": update.use_tls,
"use_starttls": update.use_starttls,
"timeout_seconds": update.timeout_seconds,
}
@router.get("/smtp")
async def get_smtp_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"smtp": _serialize_smtp_payload(await get_setting_payload(db, "smtp"))}
@router.put("/smtp")
async def update_smtp_settings(
payload: SMTPSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "super_admin"):
raise HTTPException(status_code=403, detail="Only administrators can change SMTP settings")
current = await get_setting_payload(db, "smtp")
merged = _build_smtp_payload(current, payload)
saved = await save_setting_payload(db, "smtp", merged)
return {"status": "updated", "smtp": _serialize_smtp_payload(saved)}
@router.post("/smtp/test")
async def test_smtp_settings(
payload: SMTPTestRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "super_admin"):
raise HTTPException(status_code=403, detail="Only administrators can test SMTP settings")
from app.services.email import EmailError, send_email
current = await get_setting_payload(db, "smtp")
config = _build_smtp_payload(current, payload.settings) if payload.settings else current
if not config.get("host") or not config.get("from_address"):
raise HTTPException(
status_code=400,
detail="Host and from_address are required to send a test email",
)
try:
await send_email(
db,
to=payload.to,
subject="Planet SMTP test",
text_body="This is a test email from Planet SMTP settings.",
html_body="<p>This is a test email from Planet SMTP settings.</p>",
config=config,
)
except EmailError as exc:
return {"success": False, "message": str(exc), "code": exc.code}
return {"success": True, "message": "Test email sent"}
@router.get("/tv")
async def get_tv_settings(
current_user: User = Depends(get_current_user),
@@ -1033,10 +1245,11 @@ async def connect_ai_provider_integration(
)
)
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {})
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": draft_ai_payload, "web_search": current_web_search},
{"ai_provider": draft_ai_payload, "web_search": current_web_search, "ocr": current_ocr},
)
return {
"success": True,
@@ -1110,6 +1323,21 @@ async def reveal_web_search_secrets(
}
@router.get("/integrations/ocr/secrets")
async def reveal_ocr_secrets(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
ocr_payload = _normalize_ocr_payload(current_payload.get("ocr") or {})
api_key, api_key_source = _resolve_ocr_api_key(ocr_payload)
return {
"provider": ocr_payload["provider"],
"api_key": api_key,
"api_key_source": api_key_source,
}
@router.post("/integrations/web-search/connect")
async def connect_web_search_integration(
payload: WebSearchIntegrationUpdate,

View File

@@ -0,0 +1,39 @@
"""Bounded vessel snapshot APIs for viewport-first consumers."""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.visualization import _parse_bbox, build_vessel_snapshot_response
from app.db.session import get_db
from app.services.vessel_ais_aggregation import MAX_SNAPSHOT_LIMIT
router = APIRouter()
@router.get("/snapshot")
async def get_vessel_snapshot(
bbox: Optional[str] = Query(None, description="Viewport bbox as lon_min,lat_min,lon_max,lat_max"),
zoom: int = Query(..., ge=1, le=20, description="Current map zoom level"),
type: Optional[str] = Query(
None,
description="Comma-separated vessel types: cargo,tanker,passenger,fishing,military,other",
),
limit: int = Query(1000, ge=1, le=MAX_SNAPSHOT_LIMIT),
since_minutes: int = Query(60, ge=1, le=1440),
db: AsyncSession = Depends(get_db),
):
if not bbox:
raise HTTPException(status_code=400, detail="bbox is required")
parsed_bbox = _parse_bbox(bbox)
if parsed_bbox is None:
raise HTTPException(status_code=400, detail="bbox is required")
return await build_vessel_snapshot_response(
db,
bbox=parsed_bbox,
zoom=zoom,
type_filter=type,
limit=limit,
since_minutes=since_minutes,
)

View File

@@ -51,8 +51,10 @@ from app.services.vessel_ais_aggregation import (
get_aggregated_vessel,
get_aggregated_vessel_track,
get_aggregated_vessels,
get_aggregated_vessels_snapshot,
get_vessel_conflict_records,
get_vessel_raw_observations,
MAX_SNAPSHOT_LIMIT,
)
from app.core.logging import get_logger
@@ -960,6 +962,52 @@ def _build_vessel_stats(features: List[dict[str, Any]]) -> dict[str, Any]:
}
def _safe_vessel_limit(value: int | None, *, default: int = 1000) -> int:
if value is None or value <= 0:
return default
return min(value, MAX_SNAPSHOT_LIMIT)
async def build_vessel_snapshot_response(
db: AsyncSession,
*,
bbox: tuple[float, float, float, float] | None,
zoom: int | None,
type_filter: str | None,
limit: int | None,
since_minutes: int = 60,
) -> dict[str, Any]:
requested_types = _requested_vessel_types(type_filter)
safe_limit = _safe_vessel_limit(limit)
safe_since_minutes = min(max(int(since_minutes or 60), 1), 1440)
observed_since = datetime.now(UTC) - timedelta(minutes=safe_since_minutes)
features, diagnostics = await _load_raw_vessel_snapshot_features(
db,
bbox=bbox,
limit=safe_limit,
observed_since=observed_since,
)
features = _filter_vessel_features(
features,
bbox=bbox,
requested_types=requested_types,
)[:safe_limit]
return {
"type": "FeatureCollection",
"features": features,
"count": len(features),
"stats": _build_vessel_stats(features),
"diagnostics": {
**diagnostics,
"filtered_count": len(features),
"bbox_applied": bbox is not None,
"zoom": zoom,
"limit": safe_limit,
"since_minutes": safe_since_minutes,
},
}
def convert_bgp_anomalies_to_geojson(
records: List[BGPAnomaly],
geography_hints: Optional[Dict[str, Dict[str, Any]]] = None,
@@ -2042,66 +2090,58 @@ async def get_vessels_geojson(
),
db: AsyncSession = Depends(get_db),
):
"""Return latest vessel positions as GeoJSON points."""
parsed_bbox = _parse_bbox(bbox)
requested_types = _requested_vessel_types(type)
merged_features, diagnostics = await _load_merged_vessel_features(db)
features = _filter_vessel_features(
merged_features,
bbox=parsed_bbox,
requested_types=requested_types,
"""Legacy vessel endpoint removed in favor of /api/v1/vessels/snapshot."""
raise HTTPException(
status_code=410,
detail=(
"Legacy vessel GeoJSON endpoint has been removed. "
"Use /api/v1/vessels/snapshot with bbox, zoom, and limit."
),
)
if limit and limit > 0:
features = features[:limit]
return {
"type": "FeatureCollection",
"features": features,
"count": len(features),
"stats": _build_vessel_stats(features),
"diagnostics": {
**diagnostics,
"filtered_count": len(features),
},
}
async def _load_merged_vessel_features(db: AsyncSession) -> tuple[list[dict[str, Any]], dict[str, Any]]:
aggregated_vessels = await get_aggregated_vessels(db)
async def _load_raw_vessel_snapshot_features(
db: AsyncSession,
*,
bbox: tuple[float, float, float, float] | None,
limit: int,
observed_since: datetime,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
if bbox is None:
aggregated_vessels = await get_aggregated_vessels(
db,
limit=limit,
observed_since=observed_since,
)
else:
aggregated_vessels = await get_aggregated_vessels_snapshot(
db,
bbox=bbox,
limit=limit,
observed_since=observed_since,
)
raw_geojson = convert_aggregated_vessels_to_geojson(aggregated_vessels)
latest_times = (
select(
VesselPosition.mmsi.label("mmsi"),
func.max(VesselPosition.received_at).label("received_at"),
)
.group_by(VesselPosition.mmsi)
.subquery()
)
stmt = (
select(VesselPosition, VesselStatic)
.join(
latest_times,
(VesselPosition.mmsi == latest_times.c.mmsi)
& (VesselPosition.received_at == latest_times.c.received_at),
)
.outerjoin(VesselStatic, VesselStatic.mmsi == VesselPosition.mmsi)
.order_by(VesselPosition.received_at.desc())
)
result = await db.execute(stmt)
rows = list(result.all())
legacy_geojson = convert_vessels_to_geojson(rows)
merged_features, diagnostics = _merge_vessel_features(
raw_geojson.get("features", []),
legacy_geojson.get("features", []),
)
return merged_features, {
**diagnostics,
"raw_feature_count": len(raw_geojson.get("features", [])),
"legacy_feature_count": len(legacy_geojson.get("features", [])),
features = raw_geojson.get("features", [])
return features, {
"raw_feature_count": len(features),
"raw_unique_mmsi": len(
{
key
for key in (_feature_mmsi_key(feature) for feature in features)
if key is not None
}
),
"legacy_feature_count": 0,
"legacy_backfilled_mmsi": 0,
"final_unique_mmsi": len(
{
key
for key in (_feature_mmsi_key(feature) for feature in features)
if key is not None
}
),
}
@router.get("/vessels/custom-supplements")
async def get_vessel_custom_supplements(db: AsyncSession = Depends(get_db)):
"""Group custom vessel_ais sources by their declared merge target for diagnostics."""

View File

@@ -1,7 +1,6 @@
"""WebSocket API endpoints"""
import asyncio
import json
from datetime import UTC, datetime
from typing import Optional
@@ -95,14 +94,44 @@ async def websocket_endpoint(
}
)
elif data.get("type") == "subscribe":
channels = data.get("data", {}).get("channels", [])
payload_data = data.get("data", {})
if not isinstance(payload_data, dict):
payload_data = {}
channels = payload_data.get("channels", [])
if isinstance(channels, str):
channels = [channels]
elif not isinstance(channels, list):
channels = []
channel = payload_data.get("channel")
if channel and channel not in channels:
channels = [*channels, channel]
if is_anonymous:
channels = [channel for channel in channels if channel in supported_channels]
vessel_subscription = None
if "vessels" in channels and "bbox" in payload_data:
try:
vessel_subscription = manager.subscribe_vessels(websocket, payload_data)
except ValueError as exc:
await websocket.send_json(
{
"type": "subscription_error",
"data": {"channel": "vessels", "detail": str(exc)},
}
)
continue
channels = [channel for channel in channels if channel != "vessels"]
manager.subscribe(websocket, channels)
await websocket.send_json(
{
"type": "subscription_confirmed",
"data": {"action": "subscribe", "channels": channels},
"data": {
"action": "subscribe",
"channels": [
*channels,
*(["vessels"] if vessel_subscription else []),
],
"vessels": vessel_subscription,
},
}
)
elif data.get("type") == "unsubscribe":

View File

@@ -2,7 +2,7 @@
import asyncio
from datetime import UTC, datetime
from typing import Dict, Any, Optional
from typing import Dict, Any
from app.core.time import to_iso8601_utc
from app.core.websocket.manager import manager
@@ -15,6 +15,8 @@ class DataBroadcaster:
def __init__(self):
self.running = False
self.tasks: Dict[str, asyncio.Task] = {}
self._pending_vessel_updates: Dict[str, Dict[str, Any]] = {}
self._vessel_flush_interval = 1.0
async def get_dashboard_stats(self) -> Dict[str, Any]:
"""Get dashboard statistics"""
@@ -68,6 +70,9 @@ class DataBroadcaster:
async def broadcast_custom(self, channel: str, data: Dict[str, Any]):
"""Broadcast custom data to a specific channel"""
if channel == "vessels":
self.enqueue_vessel_update(data)
return
await manager.broadcast(
{
"type": "data_frame",
@@ -78,6 +83,58 @@ class DataBroadcaster:
channel=channel,
)
def enqueue_vessel_update(self, data: Dict[str, Any]):
vessels = data.get("vessels") if isinstance(data, dict) else None
if not isinstance(vessels, list):
return
source = data.get("source")
action = data.get("action") or "upsert"
created = data.get("created")
for vessel in vessels:
if not isinstance(vessel, dict):
continue
mmsi = vessel.get("mmsi")
if mmsi in (None, ""):
continue
self._pending_vessel_updates[str(mmsi)] = {
**vessel,
"_source": source,
"_action": action,
"_created": created,
}
async def flush_vessel_updates(self):
if not self._pending_vessel_updates:
return
pending = self._pending_vessel_updates
self._pending_vessel_updates = {}
vessels = []
for item in pending.values():
vessel = dict(item)
source = vessel.pop("_source", None)
action = vessel.pop("_action", "upsert")
created = vessel.pop("_created", None)
vessel["source"] = source
vessel["action"] = action
vessel["created"] = created
vessels.append(vessel)
await manager.broadcast_vessels(
{
"action": "upsert",
"source": "mixed",
"created": None,
"vessels": vessels,
}
)
async def broadcast_vessels_periodically(self):
while self.running:
try:
await self.flush_vessel_updates()
except Exception:
pass
await asyncio.sleep(self._vessel_flush_interval)
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
"""Broadcast datasource task progress updates to connected clients."""
await manager.broadcast(
@@ -95,6 +152,7 @@ class DataBroadcaster:
if not self.running:
self.running = True
self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5))
self.tasks["vessels"] = asyncio.create_task(self.broadcast_vessels_periodically())
def stop(self):
"""Stop all broadcasters"""
@@ -102,6 +160,7 @@ class DataBroadcaster:
for task in self.tasks.values():
task.cancel()
self.tasks.clear()
self._pending_vessel_updates.clear()
broadcaster = DataBroadcaster()

View File

@@ -1,11 +1,16 @@
"""WebSocket Connection Manager"""
from typing import Dict, Set, Optional
from datetime import UTC, datetime
from typing import Any, Dict, Set, Optional
from fastapi import WebSocket
import redis.asyncio as redis
from app.core.config import settings
MAX_VESSEL_SUBSCRIPTION_LIMIT = 5000
MAX_VESSEL_WS_MESSAGE_ITEMS = 1000
MAX_VESSEL_BBOX_AREA = 2500.0
class ConnectionManager:
"""Manages WebSocket connections"""
@@ -14,6 +19,7 @@ class ConnectionManager:
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
self.channel_subscriptions: Dict[str, Set[WebSocket]] = {}
self.websocket_channels: Dict[WebSocket, Set[str]] = {}
self.vessel_subscriptions: Dict[WebSocket, dict[str, Any]] = {}
self.redis_client: Optional[redis.Redis] = None
async def connect(self, websocket: WebSocket, user_id: str):
@@ -72,6 +78,50 @@ class ConnectionManager:
channels = list(self.websocket_channels.get(websocket, set()))
if channels:
self.unsubscribe(websocket, channels)
self.vessel_subscriptions.pop(websocket, None)
def subscribe_vessels(self, websocket: WebSocket, config: dict[str, Any]) -> dict[str, Any]:
subscription = self._normalize_vessel_subscription(config)
self.channel_subscriptions.setdefault("vessels", set()).add(websocket)
self.websocket_channels.setdefault(websocket, set()).add("vessels")
self.vessel_subscriptions[websocket] = subscription
return subscription
def _normalize_vessel_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
bbox = config.get("bbox")
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
raise ValueError("vessels subscription requires bbox=[lon_min,lat_min,lon_max,lat_max]")
try:
lon_min, lat_min, lon_max, lat_max = [float(value) for value in bbox]
except (TypeError, ValueError) as exc:
raise ValueError("bbox values must be numbers") from exc
if lat_min > lat_max:
lat_min, lat_max = lat_max, lat_min
if lon_min > lon_max:
lon_min, lon_max = lon_max, lon_min
if not (-180 <= lon_min <= 180 and -180 <= lon_max <= 180):
raise ValueError("bbox longitude values must be between -180 and 180")
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90):
raise ValueError("bbox latitude values must be between -90 and 90")
if (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
raise ValueError("bbox is too large; zoom in or request a smaller viewport")
zoom = int(config.get("zoom") or 1)
if zoom < 1 or zoom > 20:
raise ValueError("zoom must be between 1 and 20")
limit = min(max(int(config.get("limit") or 1000), 1), MAX_VESSEL_SUBSCRIPTION_LIMIT)
vessel_types = {
str(item).strip().lower()
for item in str(config.get("type") or "").split(",")
if str(item).strip()
}
return {
"bbox": (lon_min, lat_min, lon_max, lat_max),
"zoom": zoom,
"limit": limit,
"type": vessel_types,
"last_sent_at": None,
}
async def send_personal_message(self, message: dict, user_id: str):
if user_id in self.active_connections:
@@ -92,6 +142,58 @@ class ConnectionManager:
except Exception:
self.unsubscribe_all(connection)
async def broadcast_vessels(self, data: dict[str, Any]):
vessels = data.get("vessels") if isinstance(data, dict) else None
if not isinstance(vessels, list) or not vessels:
return
for connection, subscription in list(self.vessel_subscriptions.items()):
matched = [
vessel
for vessel in vessels
if self._vessel_matches_subscription(vessel, subscription)
][: min(subscription["limit"], MAX_VESSEL_WS_MESSAGE_ITEMS)]
if not matched:
continue
subscription["last_sent_at"] = datetime.now(UTC)
message = {
"type": "data_frame",
"channel": "vessels",
"timestamp": subscription["last_sent_at"].isoformat(),
"payload": {
**data,
"vessels": matched,
"subscription": {
"bbox": list(subscription["bbox"]),
"zoom": subscription["zoom"],
"limit": subscription["limit"],
},
},
}
try:
await connection.send_json(message)
except Exception:
self.unsubscribe_all(connection)
def _vessel_matches_subscription(
self,
vessel: dict[str, Any],
subscription: dict[str, Any],
) -> bool:
try:
lon = float(vessel.get("lon"))
lat = float(vessel.get("lat"))
except (TypeError, ValueError):
return False
lon_min, lat_min, lon_max, lat_max = subscription["bbox"]
if not (lon_min <= lon <= lon_max and lat_min <= lat <= lat_max):
return False
requested_types = subscription.get("type") or set()
if not requested_types:
return True
type_name = str(vessel.get("vessel_type_name") or "").lower()
return any(requested_type in type_name for requested_type in requested_types)
async def close_all(self):
for user_id in self.active_connections:
for connection in self.active_connections[user_id]:
@@ -99,6 +201,7 @@ class ConnectionManager:
self.active_connections.clear()
self.channel_subscriptions.clear()
self.websocket_channels.clear()
self.vessel_subscriptions.clear()
manager = ConnectionManager()

View File

@@ -107,6 +107,7 @@ async def ensure_default_admin_user(session: AsyncSession):
password_hash=get_password_hash(default_user["password"]),
role=default_user["role"],
is_active=True,
email_verified=True,
)
)
await session.commit()
@@ -148,14 +149,31 @@ async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
users_email_verified_existed = (
await conn.execute(
text(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'email_verified'
"""
)
)
).fetchone() is not None
await conn.execute(
text(
"""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS gatekeeper_groups JSONB DEFAULT '[]'::jsonb
ADD COLUMN IF NOT EXISTS gatekeeper_groups JSONB DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS pending_email VARCHAR(255)
"""
)
)
if not users_email_verified_existed:
await conn.execute(
text("UPDATE users SET email_verified = TRUE WHERE email_verified = FALSE")
)
await conn.execute(
text(
"""
@@ -216,6 +234,26 @@ async def init_db():
"""
)
)
await conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_ais_raw_schema_observed_desc
ON ais_raw_observations (target_schema, observed_at DESC)
"""
)
)
await conn.execute(
text(
"""
CREATE INDEX IF NOT EXISTS idx_ais_raw_payload_lon_lat
ON ais_raw_observations (
((normalized_payload->>'lon')::double precision),
((normalized_payload->>'lat')::double precision)
)
WHERE target_schema = 'vessel_ais'
"""
)
)
await conn.execute(
text(
"""

View File

@@ -14,6 +14,8 @@ class User(Base):
role = Column(String(20), default="viewer")
gatekeeper_groups = Column(JSON, default=list)
is_active = Column(Boolean, default=True)
email_verified = Column(Boolean, default=False, nullable=False)
pending_email = Column(String(255), nullable=True)
last_login_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(

View File

@@ -39,7 +39,34 @@ class UserResponse(UserBase):
role: str
gatekeeper_groups: list[str] = Field(default_factory=list)
is_active: bool
email_verified: bool = False
created_at: datetime
class Config:
from_attributes = True
class UserRegister(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: EmailStr
password: str = Field(..., min_length=8, max_length=128)
class VerifyEmailRequest(BaseModel):
email: EmailStr
code: str = Field(..., min_length=6, max_length=6)
class ResendCodeRequest(BaseModel):
email: EmailStr
purpose: str = Field(default="register", pattern="^(register|verify_email|reset_password)$")
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
email: EmailStr
code: str = Field(..., min_length=6, max_length=6)
new_password: str = Field(..., min_length=8, max_length=128)

View File

@@ -138,7 +138,7 @@ class AISStreamCollector(BaseCollector):
try:
import websockets
except ImportError as exc:
except ImportError:
return {"status": "failed", "error": "Python package 'websockets' is required for AISStream"}
start_time = datetime.now(UTC)

View File

@@ -53,6 +53,7 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"),
DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 34, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"),
DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"),
DocsMetadata("ops-runbook.md", "ops-runbook", "docs_admin", "Ops", 49, "Planet 运维手册", "Planet Ops Runbook"),
DocsMetadata("ops-docker-compose-buildx-upgrade.md", "ops-docker-compose-buildx-upgrade", "docs_admin", "Ops", 50, "Docker + Compose + Buildx 升级", "Docker + Compose + Buildx Upgrade"),
DocsMetadata("ops-planet-sh-startup.md", "ops-planet-sh-startup", "docs_admin", "Ops", 51, "planet.sh 启动机制", "planet.sh Startup"),
)

View File

@@ -0,0 +1,123 @@
"""SMTP-backed email sender.
Generic primitive used by registration/verification today, reusable for alert
digests and other notifications later. Configuration lives in the `smtp` row of
`system_settings` and is loaded once per send (small surface, no caching layer
yet to keep behavior obvious after settings changes).
"""
from __future__ import annotations
from email.message import EmailMessage
from typing import Literal, Optional
import aiosmtplib
from sqlalchemy.ext.asyncio import AsyncSession
OtpPurpose = Literal["register", "verify_email", "reset_password"]
class EmailError(Exception):
code: str = "EMAIL_ERROR"
class EmailNotConfiguredError(EmailError):
code = "EMAIL_PROVIDER_NOT_CONFIGURED"
class EmailSendError(EmailError):
code = "EMAIL_SEND_FAILED"
async def _load_smtp_config(db: AsyncSession) -> dict:
from app.api.v1.settings import get_setting_payload # local import avoids cycle
payload = await get_setting_payload(db, "smtp")
if not payload.get("host") or not payload.get("from_address"):
raise EmailNotConfiguredError("SMTP host/from_address not set")
return payload
async def send_email(
db: AsyncSession,
*,
to: str,
subject: str,
text_body: str,
html_body: Optional[str] = None,
config: Optional[dict] = None,
) -> None:
cfg = config or await _load_smtp_config(db)
message = EmailMessage()
from_name = (cfg.get("from_name") or "").strip()
from_address = cfg["from_address"]
message["From"] = f"{from_name} <{from_address}>" if from_name else from_address
message["To"] = to
message["Subject"] = subject
message.set_content(text_body)
if html_body:
message.add_alternative(html_body, subtype="html")
use_tls = bool(cfg.get("use_tls", True))
use_starttls = bool(cfg.get("use_starttls", False))
port = int(cfg.get("port") or (465 if use_tls else 587))
try:
await aiosmtplib.send(
message,
hostname=cfg["host"],
port=port,
username=cfg.get("username") or None,
password=cfg.get("password") or None,
use_tls=use_tls and not use_starttls,
start_tls=use_starttls,
timeout=int(cfg.get("timeout_seconds") or 20),
)
except aiosmtplib.SMTPException as exc:
raise EmailSendError(str(exc)) from exc
except OSError as exc:
raise EmailSendError(str(exc)) from exc
_SUBJECTS: dict[OtpPurpose, str] = {
"register": "Confirm your Planet account",
"verify_email": "Verify your Planet email",
"reset_password": "Reset your Planet password",
}
_HEADLINES: dict[OtpPurpose, str] = {
"register": "Welcome to Planet — confirm your email to activate your account.",
"verify_email": "Confirm your new email address to keep your Planet account active.",
"reset_password": "Use this code to set a new password for your Planet account.",
}
async def send_verification_email(
db: AsyncSession,
*,
to: str,
code: str,
purpose: OtpPurpose,
config: Optional[dict] = None,
) -> None:
subject = _SUBJECTS[purpose]
headline = _HEADLINES[purpose]
text_body = (
f"{headline}\n\n"
f"Your verification code: {code}\n"
"This code expires in 10 minutes. If you did not request it, ignore this email.\n"
)
html_body = (
f"<p>{headline}</p>"
f"<p style=\"font-size:24px;letter-spacing:4px;font-family:monospace\"><b>{code}</b></p>"
"<p>This code expires in 10 minutes. If you did not request it, ignore this email.</p>"
)
await send_email(
db,
to=to,
subject=subject,
text_body=text_body,
html_body=html_body,
config=config,
)

100
backend/app/services/otp.py Normal file
View File

@@ -0,0 +1,100 @@
"""One-time verification codes backed by Redis.
Reusable primitive for register/verify-email/reset-password (and any future 2FA or
phone-number verification). Codes are bcrypt-hashed before storage so a Redis dump
does not leak active codes.
"""
from __future__ import annotations
import json
import secrets
from typing import Literal
import bcrypt
from app.core.security import redis_client
OtpPurpose = Literal["register", "verify_email", "reset_password"]
CODE_TTL_SECONDS = 600 # 10 minutes
RESEND_COOLDOWN_SECONDS = 60
MAX_ATTEMPTS = 5
CODE_LENGTH = 6
class OtpError(Exception):
code: str = "OTP_ERROR"
class OtpResendRateLimited(OtpError):
code = "OTP_RESEND_RATE_LIMITED"
def __init__(self, retry_after_seconds: int) -> None:
super().__init__(f"Resend allowed in {retry_after_seconds}s")
self.retry_after_seconds = retry_after_seconds
class OtpInvalid(OtpError):
code = "OTP_INVALID"
class OtpExpired(OtpError):
code = "OTP_EXPIRED"
class OtpAttemptsExceeded(OtpError):
code = "OTP_ATTEMPTS_EXCEEDED"
def _code_key(email: str, purpose: OtpPurpose) -> str:
return f"otp:{purpose}:{email.lower()}"
def _rate_key(email: str, purpose: OtpPurpose) -> str:
return f"otp_rate:{purpose}:{email.lower()}"
def _generate_code() -> str:
# secrets.randbelow gives uniform 0..10**CODE_LENGTH-1 without modulo bias
return f"{secrets.randbelow(10 ** CODE_LENGTH):0{CODE_LENGTH}d}"
def check_resend_allowed(email: str, purpose: OtpPurpose) -> None:
ttl = redis_client.ttl(_rate_key(email, purpose))
if ttl and ttl > 0:
raise OtpResendRateLimited(ttl)
def issue_code(email: str, purpose: OtpPurpose) -> str:
"""Generate a new code, persist its hash, and start the resend cooldown.
Caller is responsible for delivering the returned plaintext (e.g. via email).
Any pre-existing code for the same (purpose, email) is overwritten.
"""
check_resend_allowed(email, purpose)
code = _generate_code()
hashed = bcrypt.hashpw(code.encode(), bcrypt.gensalt()).decode()
payload = json.dumps({"hash": hashed, "attempts": 0})
redis_client.set(_code_key(email, purpose), payload, ex=CODE_TTL_SECONDS)
redis_client.set(_rate_key(email, purpose), "1", ex=RESEND_COOLDOWN_SECONDS)
return code
def verify_code(email: str, purpose: OtpPurpose, code: str) -> None:
"""Validate and consume a code. Raises subclasses of OtpError on failure."""
key = _code_key(email, purpose)
raw = redis_client.get(key)
if raw is None:
raise OtpExpired("Code expired or never issued")
record = json.loads(raw)
attempts = int(record.get("attempts", 0))
if attempts >= MAX_ATTEMPTS:
redis_client.delete(key)
raise OtpAttemptsExceeded("Too many invalid attempts")
if not bcrypt.checkpw(code.encode(), record["hash"].encode()):
record["attempts"] = attempts + 1
ttl = redis_client.ttl(key)
redis_client.set(key, json.dumps(record), ex=max(ttl, 1))
raise OtpInvalid("Incorrect code")
redis_client.delete(key)

View File

@@ -6,6 +6,7 @@ import json
from typing import Any, Iterable
from sqlalchemy import select
from sqlalchemy import Float
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.vessel import AISConflictRecord, AISRawObservation, AISSourceHealth
@@ -17,6 +18,10 @@ from app.services.vessel_types import normalize_vessel_type_name
VESSEL_AIS_SCHEMA = "vessel_ais"
DEFAULT_AGGREGATION_WINDOW_HOURS = 24
DEFAULT_SNAPSHOT_WINDOW_MINUTES = 60
MAX_SNAPSHOT_LIMIT = 5000
MAX_SNAPSHOT_CANDIDATE_MULTIPLIER = 20
MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS = 100_000
BARENTSWATCH_DELIVERY_MODE = "polling"
BARENTSWATCH_TRANSPORT = "http"
AISSTREAM_DELIVERY_MODE = "realtime_stream"
@@ -564,6 +569,46 @@ async def get_aggregated_vessels(
return vessels
async def get_aggregated_vessels_snapshot(
db: AsyncSession,
*,
bbox: tuple[float, float, float, float],
limit: int = 1000,
observed_since: datetime | None = None,
) -> list[dict[str, Any]]:
"""Return a bounded viewport snapshot without loading the global AIS window."""
observed_since = observed_since or (
datetime.now(UTC) - timedelta(minutes=DEFAULT_SNAPSHOT_WINDOW_MINUTES)
)
safe_limit = min(max(int(limit or 1000), 1), MAX_SNAPSHOT_LIMIT)
candidate_limit = min(
max(safe_limit * MAX_SNAPSHOT_CANDIDATE_MULTIPLIER, safe_limit),
MAX_SNAPSHOT_CANDIDATE_OBSERVATIONS,
)
lon_min, lat_min, lon_max, lat_max = bbox
payload_lon = AISRawObservation.normalized_payload["lon"].as_string().cast(Float)
payload_lat = AISRawObservation.normalized_payload["lat"].as_string().cast(Float)
stmt = (
select(AISRawObservation)
.where(AISRawObservation.target_schema == VESSEL_AIS_SCHEMA)
.where(AISRawObservation.observed_at >= observed_since)
.where(payload_lon >= lon_min)
.where(payload_lon <= lon_max)
.where(payload_lat >= lat_min)
.where(payload_lat <= lat_max)
.order_by(AISRawObservation.observed_at.desc(), AISRawObservation.id.desc())
.limit(candidate_limit)
)
result = await db.execute(stmt)
if not hasattr(result, "scalars"):
return []
vessels = await aggregate_vessel_observations(db, result.scalars().all())
return vessels[:safe_limit]
async def get_aggregated_vessel(db: AsyncSession, mmsi: int) -> dict[str, Any] | None:
observations = await get_vessel_raw_observations(db, mmsi, limit=1000)
vessels = await aggregate_vessel_observations(db, observations)

View File

@@ -0,0 +1,86 @@
from datetime import datetime, timedelta, timezone
import pytest
from app.api.v1 import datasources as datasources_api
from app.models.datasource import DataSource
def make_datasource(
datasource_id: int,
source: str,
*,
name: str | None = None,
module: str = "L4",
is_active: bool = True,
last_status: str | None = None,
last_run_at: datetime | None = None,
frequency_minutes: int = 60,
) -> DataSource:
return DataSource(
id=datasource_id,
name=name or source,
source=source,
module=module,
priority="P1",
frequency_minutes=frequency_minutes,
collector_class=source,
is_active=is_active,
last_status=last_status,
last_run_at=last_run_at,
)
def test_datasource_product_key_groups_domain_specific_sources():
assert datasources_api.datasource_product_key(make_datasource(1, "aisstream_vessels")) == "vessels"
assert datasources_api.datasource_product_key(make_datasource(2, "telegeography_cables")) == "cables"
assert datasources_api.datasource_product_key(make_datasource(3, "celestrak_tle")) == "satellites"
assert datasources_api.datasource_product_key(make_datasource(4, "ris_live_bgp")) == "bgp"
def test_filter_datasources_by_product_status_and_collected_state():
vessels = make_datasource(1, "aisstream_vessels", last_status="success")
cables = make_datasource(2, "telegeography_cables", last_status="failed")
filtered = datasources_api._filter_datasources_in_memory(
[vessels, cables],
running_tasks={},
record_counts={"aisstream_vessels": 12, "telegeography_cables": 0},
product="vessels",
run_status="success",
collected=True,
)
assert filtered == [vessels]
@pytest.mark.asyncio
async def test_trigger_datasource_batch_skips_disabled_and_frequency_window(monkeypatch):
now = datetime.now(timezone.utc)
disabled = make_datasource(1, "aisstream_vessels", is_active=False)
not_due = make_datasource(2, "telegeography_cables", last_run_at=now, frequency_minutes=120)
due = make_datasource(3, "ris_live_bgp", last_run_at=now - timedelta(hours=2))
triggered_sources: list[str] = []
async def fake_running_tasks(_db, _ids):
return {}
async def fake_latest_task_ids(_db, _ids):
return {}
monkeypatch.setattr(datasources_api, "_load_latest_running_tasks", fake_running_tasks)
monkeypatch.setattr(datasources_api, "_load_latest_task_ids", fake_latest_task_ids)
monkeypatch.setattr(
datasources_api,
"run_collector_now",
lambda source: triggered_sources.append(source) or True,
)
result = await datasources_api._trigger_datasource_batch(
object(),
[disabled, not_due, due],
force=False,
)
assert [item["source"] for item in result["triggered"]] == ["ris_live_bgp"]
assert {item["reason"] for item in result["skipped"]} == {"disabled", "within_frequency_window"}
assert triggered_sources == ["ris_live_bgp"]

View File

@@ -0,0 +1,44 @@
from fastapi import HTTPException
from app.api.v1 import layers
def test_layer_guard_requires_bbox():
try:
layers._parse_layer_bbox("")
except HTTPException as exc:
assert exc.status_code == 400
else:
raise AssertionError("Expected missing bbox to fail")
def test_layer_guard_filters_bbox_and_clamps_low_zoom_limit():
geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [121.0, 31.0]},
"properties": {"id": "inside"},
},
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [10.0, 10.0]},
"properties": {"id": "outside"},
},
],
}
result = layers._guard_geojson_layer(
geojson,
bbox=(120.0, 30.0, 122.0, 32.0),
zoom=2,
limit=6000,
)
assert result["returned_count"] == 1
assert result["visible_count"] == 1
assert result["features"][0]["properties"]["id"] == "inside"
assert result["diagnostics"]["limit"] == layers.LOW_ZOOM_FEATURE_LIMIT
assert result["diagnostics"]["limit_clamped"] is True
assert result["diagnostics"]["degraded"] is True

View File

@@ -0,0 +1,106 @@
"""Unit tests for app.services.otp using an in-memory Redis fake."""
from __future__ import annotations
import time
from typing import Any
import pytest
from app.services import otp
class FakeRedis:
"""Minimal subset of redis-py used by services.otp."""
def __init__(self) -> None:
self._store: dict[str, tuple[Any, float | None]] = {}
def _expired(self, key: str) -> bool:
item = self._store.get(key)
if item is None:
return True
_, expires = item
if expires is not None and expires <= time.time():
self._store.pop(key, None)
return True
return False
def set(self, key: str, value: Any, ex: int | None = None) -> None:
expires = time.time() + ex if ex else None
self._store[key] = (value, expires)
def get(self, key: str) -> Any:
if self._expired(key):
return None
return self._store[key][0]
def ttl(self, key: str) -> int:
if self._expired(key):
return -2
_, expires = self._store[key]
if expires is None:
return -1
return max(int(expires - time.time()), 0)
def delete(self, key: str) -> None:
self._store.pop(key, None)
@pytest.fixture
def fake_redis(monkeypatch):
fake = FakeRedis()
monkeypatch.setattr(otp, "redis_client", fake)
return fake
def test_issue_code_returns_six_digits(fake_redis):
code = otp.issue_code("alice@example.com", "register")
assert len(code) == 6
assert code.isdigit()
def test_verify_code_succeeds_and_consumes(fake_redis):
code = otp.issue_code("alice@example.com", "register")
otp.verify_code("alice@example.com", "register", code)
with pytest.raises(otp.OtpExpired):
otp.verify_code("alice@example.com", "register", code)
def test_verify_code_rejects_wrong_code(fake_redis):
otp.issue_code("alice@example.com", "register")
with pytest.raises(otp.OtpInvalid):
otp.verify_code("alice@example.com", "register", "000000")
def test_verify_code_locks_after_max_attempts(fake_redis):
code = otp.issue_code("alice@example.com", "register")
for _ in range(otp.MAX_ATTEMPTS):
with pytest.raises(otp.OtpInvalid):
otp.verify_code("alice@example.com", "register", "000000")
# After max attempts the next call should raise OtpAttemptsExceeded and clear the code.
with pytest.raises(otp.OtpAttemptsExceeded):
otp.verify_code("alice@example.com", "register", code)
with pytest.raises(otp.OtpExpired):
otp.verify_code("alice@example.com", "register", code)
def test_issue_code_enforces_resend_cooldown(fake_redis):
otp.issue_code("alice@example.com", "register")
with pytest.raises(otp.OtpResendRateLimited) as excinfo:
otp.issue_code("alice@example.com", "register")
assert excinfo.value.retry_after_seconds > 0
def test_issue_code_emails_are_case_insensitive(fake_redis):
code = otp.issue_code("Alice@Example.com", "register")
otp.verify_code("alice@example.com", "register", code)
def test_purposes_are_isolated(fake_redis):
register_code = otp.issue_code("alice@example.com", "register")
reset_code = otp.issue_code("alice@example.com", "reset_password")
assert register_code != reset_code
otp.verify_code("alice@example.com", "register", register_code)
# Reset code should still be valid after consuming the register code.
otp.verify_code("alice@example.com", "reset_password", reset_code)

View File

@@ -5,9 +5,12 @@ import pytest
from app.api.v1 import settings as settings_api
from app.api.v1.settings import (
AIProviderIntegrationUpdate,
OCRIntegrationUpdate,
_build_ai_provider_payload,
_build_ocr_payload,
_mask_secret,
_normalize_ai_provider_payload,
_normalize_ocr_payload,
_resolve_provider_api_key,
get_runtime_ai_provider_config,
)
@@ -130,6 +133,43 @@ def test_build_payload_keeps_saved_key_when_preview_submitted():
assert payload["providers"]["openai"]["api_key"] == "sk-old-secret"
def test_normalize_ocr_payload_adds_defaults():
payload = _normalize_ocr_payload({})
assert payload["enabled"] is False
assert payload["provider"] == "paddleocr"
assert payload["languages"] == ["zh", "en"]
assert payload["output_format"] == "markdown"
def test_build_ocr_payload_keeps_saved_key_when_preview_submitted():
current = {
"ocr": {
"enabled": True,
"provider": "custom",
"base_url": "http://localhost:8020",
"api_key": "ocr-old-secret",
}
}
update = OCRIntegrationUpdate(
enabled=True,
provider="custom",
base_url="http://localhost:8020",
api_key="**************",
model="ocr-model",
languages=["zh", "en"],
timeout_seconds=45,
max_file_size_mb=50,
output_format="json",
)
payload = _build_ocr_payload(current, update)
assert payload["api_key"] == "ocr-old-secret"
assert payload["model"] == "ocr-model"
assert payload["output_format"] == "json"
@pytest.mark.asyncio
async def test_runtime_config_uses_default_provider_specific_key(monkeypatch):
record = SimpleNamespace(

View File

@@ -0,0 +1,86 @@
"""Unit tests for SMTP settings helpers in app.api.v1.settings."""
from app.api.v1.settings import (
SMTPSettingsUpdate,
_build_smtp_payload,
_serialize_smtp_payload,
)
def test_serialize_masks_password_and_reports_configured():
serialized = _serialize_smtp_payload(
{
"host": "smtp.example.com",
"port": 587,
"username": "noreply@example.com",
"password": "super-secret",
"from_address": "noreply@example.com",
"from_name": "Planet",
"use_tls": False,
"use_starttls": True,
"timeout_seconds": 20,
}
)
assert serialized["configured"] is True
assert serialized["password"]["configured"] is True
assert "secret" not in serialized["password"]["preview"]
def test_serialize_marks_unconfigured_when_host_missing():
serialized = _serialize_smtp_payload(
{
"host": "",
"port": 587,
"from_address": "",
}
)
assert serialized["configured"] is False
assert serialized["password"]["configured"] is False
def test_build_payload_preserves_password_when_placeholder_submitted():
current = {
"host": "smtp.example.com",
"port": 587,
"username": "noreply@example.com",
"password": "super-secret",
"from_address": "noreply@example.com",
"from_name": "Planet",
"use_tls": False,
"use_starttls": True,
"timeout_seconds": 20,
}
preview = _serialize_smtp_payload(current)["password"]["preview"]
update = SMTPSettingsUpdate(
host="smtp.example.com",
port=587,
username="noreply@example.com",
password=preview,
from_address="noreply@example.com",
)
merged = _build_smtp_payload(current, update)
assert merged["password"] == "super-secret"
def test_build_payload_replaces_password_when_new_value_submitted():
current = {"password": "old", "host": "", "port": 587, "from_address": ""}
update = SMTPSettingsUpdate(
host="smtp.example.com",
port=587,
password="new-secret",
from_address="noreply@example.com",
)
merged = _build_smtp_payload(current, update)
assert merged["password"] == "new-secret"
def test_build_payload_clears_password_when_requested():
current = {"password": "old"}
update = SMTPSettingsUpdate(
host="smtp.example.com",
port=587,
from_address="noreply@example.com",
clear_password=True,
)
merged = _build_smtp_payload(current, update)
assert merged["password"] == ""

View File

@@ -523,22 +523,115 @@ def test_convert_vessels_to_geojson_dedupes_mmsi_rows():
@pytest.mark.asyncio
async def test_vessels_geojson_endpoint_filters_type_and_bbox():
async def test_vessel_snapshot_filters_type_and_bbox(monkeypatch):
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
rows = [
(
VesselPosition(mmsi=1, lat=59.9, lon=10.7, received_at=now),
VesselStatic(mmsi=1, name="Cargo Ship", vessel_type=70, vessel_type_name="Cargo"),
monkeypatch.setattr(
visualization,
"get_aggregated_vessels_snapshot",
AsyncMock(
return_value=[
{
"mmsi": 1,
"lat": 59.9,
"lon": 10.7,
"received_at": now,
"name": "Cargo Ship",
"vessel_type": 70,
"vessel_type_name": "Cargo",
},
{
"mmsi": 2,
"lat": 60.3,
"lon": 5.3,
"received_at": now - timedelta(minutes=1),
"name": "Passenger Ship",
"vessel_type": 60,
"vessel_type_name": "Passenger",
},
]
),
(
VesselPosition(mmsi=2, lat=60.3, lon=5.3, received_at=now - timedelta(minutes=1)),
VesselStatic(mmsi=2, name="Passenger Ship", vessel_type=60, vessel_type_name="Passenger"),
),
]
)
async def override_get_db():
yield object()
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/vessels/snapshot",
params={"bbox": "0,50,20,70", "zoom": 12, "type": "cargo", "limit": 1000},
)
assert response.status_code == 200
data = response.json()
assert data["count"] == 1
assert data["features"][0]["properties"]["name"] == "Cargo Ship"
assert data["stats"]["by_type"]["Cargo"] == 1
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_legacy_vessels_geojson_endpoint_is_gone():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/visualization/geo/vessels")
assert response.status_code == 410
assert "/api/v1/vessels/snapshot" in response.json()["detail"]
@pytest.mark.asyncio
async def test_vessel_snapshot_requires_bbox():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/vessels/snapshot", params={"zoom": 12})
assert response.status_code == 400
assert response.json()["detail"] == "bbox is required"
@pytest.mark.asyncio
async def test_vessel_snapshot_filters_bbox_and_caps_limit(monkeypatch):
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
captured = {}
async def fake_get_aggregated_vessels_snapshot(db, *, bbox, limit, observed_since):
captured["bbox"] = bbox
captured["limit"] = limit
captured["observed_since"] = observed_since
return [
{
"mmsi": 1,
"lat": 59.9,
"lon": 10.7,
"received_at": now,
"name": "Cargo Ship",
"vessel_type": 70,
"vessel_type_name": "Cargo",
},
{
"mmsi": 2,
"lat": 60.3,
"lon": 5.3,
"received_at": now,
"name": "Passenger Ship",
"vessel_type": 60,
"vessel_type_name": "Passenger",
},
]
monkeypatch.setattr(
visualization,
"get_aggregated_vessels_snapshot",
fake_get_aggregated_vessels_snapshot,
)
class _Result:
def all(self):
return rows
return []
class _FakeSession:
async def execute(self, _query):
@@ -552,75 +645,39 @@ async def test_vessels_geojson_endpoint_filters_type_and_bbox():
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/visualization/geo/vessels",
params={"bbox": "0,50,20,70", "type": "cargo", "limit": 0},
"/api/v1/vessels/snapshot",
params={
"bbox": "10,59,11,60",
"zoom": 12,
"type": "cargo",
"limit": 5000,
"since_minutes": 30,
},
)
assert response.status_code == 200
data = response.json()
assert data["count"] == 1
assert data["features"][0]["properties"]["name"] == "Cargo Ship"
assert data["stats"]["by_type"]["Cargo"] == 1
assert captured["bbox"] == (10.0, 59.0, 11.0, 60.0)
assert captured["limit"] == 5000
assert data["diagnostics"]["bbox_applied"] is True
assert data["diagnostics"]["legacy_feature_count"] == 0
assert data["diagnostics"]["legacy_backfilled_mmsi"] == 0
finally:
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_vessels_geojson_merges_raw_and_legacy_sources(monkeypatch):
now = datetime(2026, 4, 28, 1, 0, tzinfo=timezone.utc)
monkeypatch.setattr(
visualization,
"get_aggregated_vessels",
AsyncMock(
return_value=[
{
"mmsi": 1,
"lat": 59.9,
"lon": 10.7,
"received_at": now,
"name": "AISSTREAM SHIP",
"vessel_type_name": "Cargo",
"source_summary": {"aisstream_vessels": {"message_types": ["PositionReport"]}},
}
]
),
)
rows = [
(
VesselPosition(mmsi=1, lat=60.0, lon=10.8, received_at=now),
VesselStatic(mmsi=1, name="LEGACY DUP", vessel_type_name="Cargo"),
),
(
VesselPosition(mmsi=2, lat=60.3, lon=5.3, received_at=now),
VesselStatic(mmsi=2, name="BARENTSWATCH ONLY", vessel_type_name="Passenger"),
),
]
class _Result:
def all(self):
return rows
class _FakeSession:
async def execute(self, _query):
return _Result()
async def override_get_db():
yield _FakeSession()
app.dependency_overrides[get_db] = override_get_db
async def test_legacy_vessels_geojson_rejects_even_with_bbox():
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/visualization/geo/vessels")
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/v1/visualization/geo/vessels",
params={"bbox": "10,59,11,60", "type": "cargo", "limit": 1000},
)
assert response.status_code == 200
data = response.json()
names = {feature["properties"]["mmsi"]: feature["properties"]["name"] for feature in data["features"]}
assert data["count"] == 2
assert names == {1: "AISSTREAM SHIP", 2: "BARENTSWATCH ONLY"}
assert data["diagnostics"]["legacy_backfilled_mmsi"] == 1
finally:
app.dependency_overrides.clear()
assert response.status_code == 410
@pytest.mark.asyncio

View File

@@ -1,6 +1,8 @@
import pytest
import importlib
from app.core.websocket.manager import ConnectionManager
from app.core.websocket.broadcaster import DataBroadcaster
class FakeWebSocket:
@@ -44,3 +46,93 @@ async def test_disconnect_removes_channel_subscriptions():
assert socket.sent == []
assert "dashboard" not in manager.channel_subscriptions
@pytest.mark.asyncio
async def test_vessel_subscribers_receive_only_matching_bbox_updates():
manager = ConnectionManager()
oslo_socket = FakeWebSocket()
bergen_socket = FakeWebSocket()
await manager.connect(oslo_socket, "user-1")
await manager.connect(bergen_socket, "user-2")
manager.subscribe_vessels(
oslo_socket,
{"bbox": [10, 59, 11, 60], "zoom": 12, "limit": 1000},
)
manager.subscribe_vessels(
bergen_socket,
{"bbox": [5, 60, 6, 61], "zoom": 12, "limit": 1000},
)
await manager.broadcast_vessels(
{
"action": "upsert",
"vessels": [
{"mmsi": 1, "lat": 59.9, "lon": 10.7},
{"mmsi": 2, "lat": 60.3, "lon": 5.3},
],
}
)
assert oslo_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 1, "lat": 59.9, "lon": 10.7}]
assert bergen_socket.sent[0]["payload"]["vessels"] == [{"mmsi": 2, "lat": 60.3, "lon": 5.3}]
@pytest.mark.asyncio
async def test_vessel_broadcast_removes_slow_connections():
manager = ConnectionManager()
class BrokenWebSocket(FakeWebSocket):
async def send_json(self, message):
raise RuntimeError("client is gone")
socket = BrokenWebSocket()
await manager.connect(socket, "user-1")
manager.subscribe_vessels(socket, {"bbox": [10, 59, 11, 60], "zoom": 12})
await manager.broadcast_vessels({"vessels": [{"mmsi": 1, "lat": 59.9, "lon": 10.7}]})
assert socket not in manager.vessel_subscriptions
def test_vessel_subscription_rejects_large_bbox():
manager = ConnectionManager()
with pytest.raises(ValueError, match="bbox is too large"):
manager.subscribe_vessels(FakeWebSocket(), {"bbox": [-180, -90, 180, 90], "zoom": 1})
@pytest.mark.asyncio
async def test_vessel_broadcaster_keeps_latest_update_per_mmsi(monkeypatch):
sent = []
async def fake_broadcast_vessels(payload):
sent.append(payload)
broadcaster_module = importlib.import_module("app.core.websocket.broadcaster")
monkeypatch.setattr(broadcaster_module.manager, "broadcast_vessels", fake_broadcast_vessels)
broadcaster = DataBroadcaster()
broadcaster.enqueue_vessel_update(
{
"source": "aisstream_vessels",
"vessels": [
{"mmsi": 1, "lat": 59.0, "lon": 10.0},
{"mmsi": 1, "lat": 59.1, "lon": 10.1},
],
}
)
await broadcaster.flush_vessel_updates()
assert len(sent) == 1
assert sent[0]["vessels"] == [
{
"mmsi": 1,
"lat": 59.1,
"lon": 10.1,
"source": "aisstream_vessels",
"action": "upsert",
"created": None,
}
]

View File

@@ -8,6 +8,22 @@ This project follows the repository versioning rule:
- `improvement` -> `+0.0.1`bugfix + 小功能混合)
- `bugfix` -> `+0.0.1`
## [0.52.0] — 2026-05-12
Released: 2026-05-12
### Highlights
- 新增邮箱验证码注册/找回密码链路、SMTP 设置面板和连接测试组件,补齐公开账号自助入口。
- 重构 AIS 船只实时链路:新增受控船只 snapshot、WebSocket vessels 订阅、AISStream 长连接状态与节流广播。
- 新增数据产品统计接口、受控 `/layers/*` 图层接口骨架,以及数据源产品域筛选和批量采集。
### Added / Fixed / Improved
- 新增 `/api/v1/data-products``/api/v1/layers/*``/api/v1/datasources/trigger-batch`,拆分全量统计与地图渲染数据。
- 将 Earth 船只层迁移到 `/api/v1/vessels/snapshot`,并修正 vessels WebSocket 订阅 payload。
- 更新中英文手册、采集器文档、运维手册和计划状态覆盖注册、SMTP、AISStream、数据产品和图层保护流程。
---
## [0.51.1] — 2026-05-11
Released: 2026-05-11

View File

@@ -2,6 +2,16 @@
This file contains Planet-specific documentation coverage rules. Documentation skills and agents should read this file before deciding which docs to update. Keep tool-specific workflow in skills; keep product and repository rules here.
## Audience Routing (mandatory)
Before deciding scope, classify the change by who performs the action:
- **Browser/UI end user** (login, account settings, configuring collectors or AI via UI, using Earth/Console pages): update `docs/technical/{zh,en}/manual.md` and `quickstart.md` only. Never put shell commands, log file paths, `planet.sh`, Docker operations, or `netsh portproxy` rules into these files.
- **Operations / deployment / on-call** (`planet.sh`, log paths, SMTP fallbacks like `createuser`, LAN/portproxy, env-var tuning, troubleshooting order, Bun build conventions): update `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`). Never put UI button labels or screenshots into these files.
- **Second-party developers** (component context, render order, internal pipelines): update the existing `*-context.md` / `backend-*.md` / `earth-*.md` files.
If the same action has both a UI and a CLI path (e.g. user creation), describe the UI path in `manual.md` and the CLI path in `ops-runbook.md`, and cross-link them with a single sentence each.
## Scope Rules
- User-visible workflow changes must update `docs/technical/zh/manual.md` and usually `docs/technical/zh/quickstart.md`.

View File

@@ -0,0 +1,146 @@
# 数据产品流水线、数据源批量运维与抗击穿图层接口计划
## Summary
前端展示分两类数据:
- **图层数据**:按 viewport、bbox、zoom、limit 返回,可降级、截断、缓存,用来保护服务器。
- **聚合面板统计**:必须是全量统计,不受当前 viewport 限制,但不能实时扫全表;通过产品状态表或预计算统计提供。
也就是说:地图上低 zoom 可以只画摘要或局部数据但面板里的“总船只数、总海缆数、BGP 活跃事件数、卫星数”等应该代表全局数据产品状态。
## Implementation Status
- 已新增 `POST /api/v1/datasources/trigger-batch`,支持按选中 `source_ids` 或筛选条件批量触发,并返回 `triggered/skipped/failed`
- 已改造 `/datasources` 页面,支持产品域、层级、启用状态、最近执行状态、是否已有数据和关键词筛选,并支持复选框批量采集。
- 已新增 `/api/v1/data-products``/api/v1/data-products/{product_id}/status`,聚合面板可以读取全量/全局统计口径。
- 已新增 `/api/v1/layers/*` 受控图层接口骨架,要求 `bbox/zoom/limit`,响应包含 `visible_count/returned_count/diagnostics`
- 非船只图层当前先复用已有 GeoJSON 转换再做保护层;下一步应把 cables/BGP/satellites 的 bbox 过滤继续下推到各自产品查询,避免转换前仍加载过多候选。
## Key Changes
- 新增数据产品状态/统计层:
- 每个产品维护全量统计:总实体数、活跃数、最近更新时间、使用源、缺失源、冲突数、构建状态。
- 统计在采集成功或产品投影完成后更新,不在用户打开页面时临时全表聚合。
- 前端聚合面板统一读取产品统计接口,而不是从图层返回量推断总数。
- 新增接口:
- `GET /api/v1/data-products`
- `GET /api/v1/data-products/{product_id}/status`
- `GET /api/v1/layers/{product}/...`
- `POST /api/v1/datasources/trigger-batch`
- `/layers/*` 只负责可视化数据:
- 支持 bbox、zoom、limit、since。
- 可以返回 `degraded``truncated``cache_hit`
- 返回 `visible_count``returned_count`,但不作为全量统计来源。
- `/data-products/*/status` 负责全量统计:
- 返回 `total_count``active_count``source_counts``last_built_at``health`
- 数据来自预计算状态或轻量索引统计。
- 即使图层降级,统计也保持全量口径。
## Product Processing
- 船只:
- 图层bbox snapshot + WS 聚合流,受限返回。
- 统计:全量唯一 MMSI、最近窗口活跃 MMSI、AISStream/BarentsWatch/source counts。
- 海缆:
- 图层viewport 内 cable segments/landing points低 zoom 可简化路线。
- 统计:全量 cable count、landing point count、relation count、graph 构建状态。
- 处理:专用 cable graph assembler区分路线源、登陆点源、关系源、补充源不使用统一字段融合函数。
- BGP
- 图层active incidents/anomalies/collectors按窗口和 limit 返回。
- 统计:全量活跃事件、最近 24h/7d 事件数、collector 数、incident/anomaly 分布。
- 处理:专用事件流水线,区分 observation、anomaly、incident、geo hint、infrastructure inference。
- 卫星:
- 图层:可见卫星或受控 limit。
- 统计:全量卫星数、最新 TLE epoch、源覆盖情况。
- 处理:按 NORAD id 生成轨道快照TLE epoch 最新优先。
## Data Source Page
- 增加筛选:
- 产品类型、启用/禁用、最近成功/失败/运行中/未执行、已采集/未采集、凭证状态、文本搜索。
- 增加复选框批量操作:
- 批量启用、禁用、采集、强制采集。
- 一键采集改为:采集全部启用源、采集筛选结果、采集选中源。
- 后端 batch 逻辑:
- 禁用源 skipped。
- 运行中源按 force 处理。
- 单个失败不影响其他源。
- 返回 `triggered``skipped``failed`,并包含每个 source 的原因和 task_id。
## Protection Rules
- 所有 `/layers/*` 接口必须有保护层:
- limit clamp。
- bbox/zoom 校验。
- 低 zoom 降级。
- 短 TTL 缓存。
- 慢查询超时。
- diagnostics 返回降级原因。
- 全量统计不走图层查询:
- 不允许为了面板统计在请求时 `.all()` 全量加载。
- 统计由采集/投影任务异步更新。
- 统计缺失时返回 `unknown``stale`,不触发重型实时计算。
- 缓存失效规则:
- 采集成功后失效对应产品缓存。
- 海缆 graph cache 在路线、登陆点或关系源成功采集后失效。
- BGP incident/anomaly 生成后失效 BGP layer cache。
- 船只实时流使用短 TTL 或 viewport 级缓存,不清全局缓存。
- 接口观测:
- 记录每个 layer endpoint 的耗时、返回数量、是否降级、是否缓存命中、limit 是否被 clamp。
- 对高频 viewport 请求增加简单 per-IP 或 per-user rate limit。
## Frontend UX
- 数据源页:
- 顶部统计可作为快捷筛选入口:全部、启用、禁用、运行中、失败、未采集。
- 表格左侧增加复选框。
- 工具栏显示“已选择 N 个”,并提供批量按钮。
- 筛选结果和选中结果分清楚,避免误触发全部源。
- 批量采集完成后弹出摘要:触发、跳过、失败数量,可展开查看原因。
- 设置/配置页:
- “采集器设置”改为“数据产品配置”。
- 产品内按源角色分组展示,而不是简单列出 collector。
- 海缆显示路线源、登陆点源、关系源、补充源。
- BGP 显示实时观测源、历史回填源、地理 hint 源、检测输出。
- 船只显示实时 AIS、轮询 AIS、自定义补充源。
- Earth 图层交互:
- 聚合面板统计读取 `/data-products/*/status`,保持全量口径。
- 图层面板展示当前图层是否降级、截断、缓存命中。
- 对象详情展示来源证据:
- 船只:字段来源、冲突。
- 海缆:路线源、登陆点源、关系源。
- BGP事件证据、分组依据、地理推断依据。
- 产品 degraded 时仍显示可用部分,并提示缺失源角色。
## Test Plan
- 图层接口:
- 大 limit 被 clamp。
- 低 zoom 降级。
- 大数据集不全量内存过滤。
- diagnostics 正确说明截断、缓存、降级。
- 全量统计:
- 面板统计不受 bbox 影响。
- 图层返回 1000 条时,产品统计仍显示全量总数。
- 统计陈旧时返回 `stale=true``last_built_at`
- 采集成功后对应产品统计刷新。
- 数据源批量:
- 筛选、选中、批量采集行为正确。
- skipped/failed/triggered 分组正确。
- 禁用源在 batch 中被 skipped。
- 运行中源按 force 参数处理。
- batch 单源失败不阻断整体。
- 产品处理:
- 海缆缺 relation 时产品状态 degraded但 cable layer 可用。
- BGP observation 不直接变成前端 marker必须经过 anomaly/incident 投影。
- 船只 bbox snapshot 和 WS 节流继续有效。
- 卫星列表不返回无限轨道点。
## Assumptions
- 前端聚合面板以后只读 `/data-products/*/status`
- 地图图层只读 `/layers/*`
- 统计可以短暂 stale但不能因实时全量统计击穿服务器。
- 保留现有 collector不为了重构而删除 BarentsWatch 或其他源。
- 当前开发阶段允许前端从旧 `/visualization/geo/*` 迁移到 `/layers/*`

View File

@@ -0,0 +1,116 @@
# 文档受众分层重构计划
**状态**:待实施
**创建日期**2026-05-12
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md``.claude/commands/docs.md`,让以后写文档时自动按受众归档。
## 背景
当前 `manual.md` 把客户实际使用和开发/运维操作混在一份文档里:开头 200 多行讲的是 `planet.sh start/stop/restart/log/health/createuser/--allow-lan`、AI Provider 镜像构建、`netsh portproxy` 和故障排查顺序,后面才进入 Earth、控制台、AI、Docs 这些客户真正会用到的功能。
客户读到一半会被 shell 命令吓住,开发者想找运维细节又要在大段 UI 操作里翻。`documentation-coverage-rules.md` 现在也没有受众分层规则,未来文档继续混着写。
本计划假定客户已经能拿到账号登录使用 — 注册/验证流程本身见 [用户公开注册与邮箱验证计划](/home/ray/dev/linkong/planet/docs/plans/user-registration-email-verification-plan.md)。
## 新的文档地形
| 文档 | 受众 | Gatekeeper 组 | 范围 |
| --- | --- | --- | --- |
| `manual.md` (zh+en) | 纯客户/最终用户 | `public` | 注册、登录、账户、设置 UI、collector 配置、AI 配置、Console 页面、Earth、Docs 浏览 |
| `quickstart.md` (zh+en) | 纯客户 | `public` | "我刚拿到 Planet 怎么开始用" — 打开 URL → 注册 → 验证 → 登录 → 第一次配置 |
| `ops-runbook.md` (zh+en, **新增**) | 运维/部署人员 | `docs_admin` | `planet.sh` 完整命令、健康检查、日志位置、LAN/portproxy、故障排查顺序、createuser CLI、Bun 构建约定 |
| `ops-planet-sh-startup.md` (已存在) | 运维 | `docs_admin` | 启动性能、AI Provider 镜像、`PLANET_LOAD_ZSHRC_ENV` 深度调优 — 保持不动 |
| 现有 `*-context.md` / `backend-*.md` | 二次开发者 | `docs_developer` | 保持现状 |
`backend-system-service-control.md` 偏后端服务控制原理,**不**和 `ops-runbook.md` 重复 — runbook 讲"运维要敲什么命令"service-control 讲"后端怎么实现服务管控"。
## manual.md 重写后的章节顺序(客户旅程)
1. **欢迎与入口** — Planet 是什么、几个入口Earth 公开 / Console 需登录 / Docs / API
2. **注册账户** — 打开 `/login` → 点"注册" → 填用户名/邮箱/密码 → 收邮件 → 输入 6 位验证码 → 登录
3. **登录与找回密码** — 登录页、忘记密码流程
4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出
5. **Console 总览** — 左侧菜单结构、各路由用途
6. **配置数据采集器**`/settings?tab=collector_credentials`:选择 collector、连接测试、保存凭证BarentsWatch / AISStream 两个典型例子
7. **配置 AI 凭证**`/ai?tab=providers`:默认 provider、模型、Base URL、API Key、本地代理工具 tabWebSearch、OCR
8. **系统设置**`/settings` 其他子 tab系统设置、电视直播源、SMTP 邮件)
9. **用户管理(管理员)**`/users`创建、删除、改角色、Gatekeeper 权限组
10. **数据探索**`/datasources``/data``/bgp``/alerts/*`
11. **AI 测试台**`/ai?tab=playground`
12. **Earth 公开页面** — 现 manual.md 的 Earth 章节原样保留(图层、图例、搜索、位置候选、设置、视角、动捕、巡航、移动端)
13. **Docs 文档站** — 当前 Docs 章节保留(权限组说明)
不再出现:`planet.sh``./planet.sh log``netsh portproxy``source ~/.zshrc && bun run build`、"故障排查顺序"、"开发命令约定"。
## quickstart.md 重写
当前 quickstart 假设读者会自己 `git clone` 然后 `./planet.sh start`,这是给开发者看的。改为:
- 打开管理员给你的 URL
- 注册账号 + 邮箱验证
- 登录后第一次做什么(建议先到 `/settings?tab=collector_credentials` 配一个 collector再到 `/ai` 配模型)
- 看 Earth
部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。
## ops-runbook.md 内容大纲
抽自现 manual.md重新组织
1. 首次启动 — `./planet.sh start`、默认账号(`admin/admin123``linkong/12345678`,引用 `b15d097b` 引入的 `DEFAULT_LOGIN_USERS`
2. 启停与按模块重启 — `start/stop/restart``-b -f -a -d`
3. 健康检查 — `./planet.sh health`
4. 日志 — `./planet.sh log``-f -b -a`,日志文件路径
5. 创建用户CLI 兜底)— `./planet.sh createuser`说明这是公开注册不可用SMTP 未配置)时的兜底
6. 局域网/WSL 访问 — `--allow-lan``netsh portproxy`、防火墙
7. AI Provider 环境变量与构建 — `aiprovider/.env``~/.zshrc``PLANET_LOAD_ZSHRC_ENV`
8. 故障排查顺序 — 现 manual 末尾那段,原样搬来
9. 开发命令约定 — Bun、`bun run build`、为什么不用 npm
## documentation-coverage-rules.md 增量
在现有"覆盖清单"末尾新增一段:
> **受众分层(强制)**
>
> - 客户/最终用户能在浏览器里完成的操作 → 只写到 `manual.md` / `quickstart.md`
> - 需要 SSH/shell/Docker/`planet.sh`/日志文件路径/端口转发 → 只写到 `ops-runbook.md`(或现有 `ops-*.md`**禁止**出现在 manual/quickstart
> - 同一动作两种入口(如"创建用户"既能 UI 也能 CLI→ UI 路径写 manual.mdCLI 路径写 ops-runbook.md互相用一句话相互引用
> - 新增客户可见 UI 流 → 同时更新 `manual.md` zh+en 与 `docs-content.ts`
> - 新增 ops 命令或脚本 → 只更新 `ops-runbook.md` zh+en
## .claude/commands/docs.md 增量
在 "Step 2 — Decide Scope" 后插一段:
> **Document Audience Routing (Planet)**
>
> 在 Planet 仓库内,写文档前先判断动作的执行者:
>
> - 浏览器 UI 用户 → `docs/technical/{zh,en}/manual.md` / `quickstart.md`
> - shell/容器/运维 → `docs/technical/{zh,en}/ops-runbook.md` 或现有 `ops-*.md`
> - 二次开发者 → 现有 `*-context.md` / `backend-*.md`
>
> 永远不要把 shell 命令、日志路径、Docker 操作写进 manual/quickstart永远不要把 UI 截图/按钮路径写进 ops-*。
## 关键文件清单
- `docs/technical/zh/manual.md` & `en/manual.md` — 重写
- `docs/technical/zh/quickstart.md` & `en/quickstart.md` — 重写
- `docs/technical/zh/ops-runbook.md` & `en/ops-runbook.md` *(新)*
- `docs/documentation-coverage-rules.md` — 加受众分层段
- `.claude/commands/docs.md` — 加 Document Audience Routing 段
- `frontend/src/pages/Docs/docs-content.ts` — 注册 `ops-runbook``DOCS_METADATA``docs_admin` 组)
## 依赖
manual.md 的"注册账户"和"登录与找回密码"两章需要前后端注册/验证流程已经落地,否则文档会描述不存在的功能。注册功能本身见 [用户公开注册与邮箱验证计划](/home/ray/dev/linkong/planet/docs/plans/user-registration-email-verification-plan.md)。建议先实现注册再重写 manual避免文档与代码错位。
## 验证
- `rg -n 'planet\.sh' docs/technical/zh/manual.md docs/technical/en/manual.md docs/technical/zh/quickstart.md docs/technical/en/quickstart.md` 应该为空
- `rg -n '注册账户|register|邮箱验证' docs/technical/zh/manual.md docs/technical/en/manual.md` 应该有命中
- `rg -n 'planet\.sh' docs/technical/zh/ops-runbook.md docs/technical/en/ops-runbook.md` 应该有命中
- `frontend/src/pages/Docs/docs-content.ts``ops-runbook` 出现且分组为 `docs_admin`
- zh/en manual 章节标题对齐(按 `documentation-coverage-rules.md` 现有要求)
- Docs 站点访问:未登录看 manual/quickstart 正常;非 `docs_admin` 用户看不到 `ops-runbook``admin` 能看到

View File

@@ -176,10 +176,12 @@ freshness:
## 聚合接口
状态更新:开发期已直接切换到新船只快照接口。旧 `/api/v1/visualization/geo/vessels` 不再兼容返回数据,而是返回 `410 Gone`;新的 Earth 船只首屏应调用 `/api/v1/vessels/snapshot`,实时更新走 `/ws``vessels` 订阅。
现有展示接口应逐步改为消费聚合服务,而不是自己直接拼 `VesselPosition + VesselStatic`
```text
GET /api/v1/visualization/geo/vessels
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/visualization/vessels/{mmsi}
GET /api/v1/visualization/vessels/{mmsi}/track
GET /api/v1/visualization/vessels/{mmsi}/conflicts
@@ -317,7 +319,7 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图
目标是让展示接口开始消费聚合结果,但前端形状保持兼容。
1. 实现 AIS 聚合服务,先兼容读取现有表,再逐步切换到原始观测层。
2. `/geo/vessels` `/vessels/{mmsi}` 改为走聚合服务。
2.船只列表迁移到 `/api/v1/vessels/snapshot`,并让 `/vessels/{mmsi}` 走聚合服务。
3.`/vessels/{mmsi}/track` 改为走轨迹聚合逻辑。
4. 返回 `field_sources``selected_reasons``quality_flags``conflict_count`
5. 加入 freshness fallback 和异常位置保护。
@@ -342,13 +344,13 @@ VesselFinder 等服务里的船只图片不属于 AIS 实时数据本身。图
3. 聚合结果返回 `source_summary`,展示每艘船的来源、观测数量、最新观测时间、传输模式和消息类型。
4. 保留 `field_sources``selected_reasons`,用于解释动态字段来自实时流、静态字段来自可用非空来源。
5. 船名标准化会读取 AISStream `MetaData.ShipName`;船型展示会从 `vessel_type_name` 和 AIS 数字 `vessel_type` 共同归一化,保证 marker 颜色、详情卡、hover 和搜索结果一致。
6. `/geo/vessels` 不再默认限制 5000 艘;不传 `limit` 或传 `limit=0` 表示全量返回,前端默认也不再二次裁剪到 5000
6. 当前实现已转向 `/api/v1/vessels/snapshot`:必须带 bbox / zoom默认 `limit=1000`,最大 `limit=5000`,不再支持旧 `/geo/vessels` 全量返回
### v3.1 — 聚合完整性修复(v4 前置
### v3.1 — 聚合完整性修复(已被新快照接口取代
目标是先保证“所有已采集到的船都能显示”BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。
目标是先保证“所有已采集到的船都能显示”BarentsWatch 不因为接入 AISStream 而被 raw observation 聚合结果遮蔽。开发期产品尚未上线后,决策调整为直接淘汰 legacy 船只表兜底:船只快照只读取 `ais_raw_observations` 聚合结果,旧 `vessel_position + vessel_static` 不再合并进 `/api/v1/vessels/snapshot`
当前风险是 `/geo/vessels` 只要 raw observation 聚合返回非空,就直接使用 raw 聚合结果,不再补读兼容层 `vessel_position + vessel_static`。如果 raw observation 中只存在 AISStream 的几百艘船,或 BarentsWatch 历史数据没有完整回填到 raw 层,最终 Earth 就会只显示 AISStream 子集。
因此以下 legacy merge 要求作废,保留在文档中只作为历史决策记录:
1. `/geo/vessels` 必须合并 raw observation 聚合结果和 legacy latest position 结果。
2. raw 与 legacy 同一 MMSI 同时存在时只显示一艘,优先使用 raw 聚合结果及其 `field_sources` / `selected_reasons`
@@ -416,7 +418,7 @@ REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100
- `freshness.realtime_stream_seconds` / `polling_seconds` 必须为非负整数;
- `mode=locked` 必须带非空 `locked_source`
4. 聚合服务 `vessel_ais_aggregation.py``_select_position_observation` 中按 `freshness` 把过期实时流降级到 stale 候选;在 `_select_static_field` 中按 `field_rules.mode = source_priority / locked / newest / non_empty` 选源。
5. 聚合输出每条 vessel 携带 `aggregation_strategy_version`,并在 `/geo/vessels` GeoJSON properties + `/vessels/{mmsi}` 详情中暴露。
5. 聚合输出每条 vessel 携带 `aggregation_strategy_version`,并在 `/api/v1/vessels/snapshot` GeoJSON properties + `/vessels/{mmsi}` 详情中暴露。
6. API
- `GET /api/v1/vessel-aggregation/strategy`
- `PUT /api/v1/vessel-aggregation/strategy`(校验失败 400
@@ -460,8 +462,8 @@ REST collector 的自然状态是 `fetch -> transform -> save -> progress 0..100
- 明显异常位置不会进入默认展示轨迹,并会留下 `quality_flags`
- 同一时间窗口内多来源相近轨迹点只展示一个点。
- AISStream 重连或回放导致的重复消息不会重复进入聚合结果。
- raw observation 聚合结果legacy latest position 结果会按 MMSI 合并BarentsWatch-only 船只不会因为 AISStream 子集存在而消失
- 不传 `limit` 或传 `limit=0` 时,`/geo/vessels` 全量返回合并后的船只集合
- `/api/v1/vessels/snapshot` 只读取 AIS raw observation 聚合结果legacy latest position 不再参与船只快照
- `/api/v1/visualization/geo/vessels` 返回 `410 Gone`,客户端必须迁移到新 snapshot API
- AISStream 长连接收到新船、位置变化和航向变化后,会通过内部 `/ws``vessels` channel 推送增量。
- AISStream streaming 状态不会显示成固定百分比完成进度条,也不会在收到一批消息后误报采集完成。
- `mmsi``imo``callsign` 等身份编号在前端不显示千分位符。

View File

@@ -216,7 +216,7 @@ hover、locked、dimmed 可通过更新少量 instance attribute 实现,不再
### 1. 请求视口范围
前端请求 `/api/v1/visualization/geo/vessels` 时带上当前视口 `bbox`,减少无关船只
前端请求 `/api/v1/vessels/snapshot` 时必须带上当前视口 `bbox``zoom` 和受控 `limit`,减少无关船只。旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`
### 2. 后端排序策略

View File

@@ -0,0 +1,155 @@
# 用户公开注册与邮箱验证计划
**状态**:待实施
**创建日期**2026-05-12
**核心目标**:给 Planet 增加公开注册流程 + 邮箱验证码 + 忘记密码,让客户无需管理员介入就能开通账号;同时把 SMTP 邮件作为可复用基础服务接入 `/settings`
## 背景
当前认证只暴露 `/auth/login``/auth/refresh``/auth/logout``/auth/me``backend/app/api/v1/auth.py`),账号只能由 `super_admin``/users` 后台创建。User 模型 `backend/app/models/user.py` 没有 `email_verified` 字段,仓库也没有任何 SMTP/邮件发送基础设施。
客户旅程想从"打开浏览器→注册→验证→登录"开始走(见 [文档受众分层重构计划](/home/ray/dev/linkong/planet/docs/plans/docs-audience-split-plan.md)),就必须先把这条链路在代码里跑通。
注册策略(已确认):
- 开放公开注册,任何人可在 `/register` 自助开通
- 默认角色 `viewer`
- 邮箱验证后立即可登录(无需管理员审批)
- 验证仅走 SMTP 邮件6 位数字码10 分钟 TTL
## 数据模型
`backend/app/models/user.py` 加两列:
```python
email_verified = Column(Boolean, default=False, nullable=False)
pending_email = Column(String(255), nullable=True) # 改邮箱时临时落地待验证地址
```
迁移路径:仓库目前没看到 alembic 目录,沿用 `backend/app/db/session.py` 的初始化风格在启动时跑 `ALTER TABLE users ADD COLUMN IF NOT EXISTS ...`。先确认是否存在 alembic若有则正规迁移。
不另建 `verification_codes` 表 — OTP 走 **Redis**(系统已有 Redistoken blacklist 也走 Redis
```
key: otp:{purpose}:{email} purpose ∈ {register, verify_email, reset_password}
value: { code_hash: bcrypt, attempts: int, issued_at: ts }
TTL: 600 秒
```
`{purpose}:{email}` 同时配一个限流键 `otp_rate:{purpose}:{email}`TTL 60 秒,用于"60 秒内禁止重发"。
## 服务拆分
按项目 `services/` 单职责风格拆两个:
**`backend/app/services/otp.py`**(通用 OTP 原语,未来 2FA / 手机号验证可直接复用):
```python
async def issue_code(email: str, purpose: OtpPurpose) -> str # 生成 6 位、写 Redis、返回明码调用方负责送达
async def verify_code(email: str, purpose: OtpPurpose, code: str) -> bool # 校验并消耗
async def check_resend_allowed(email: str, purpose: OtpPurpose) -> None # 抛 RateLimited 异常
```
- 6 位数字,密码学随机
- Redis 存 `bcrypt(code)`,不存明码
- 校验失败计数 ≥ 5 直接失效该 key
- 重发触发即失效旧 code
**`backend/app/services/email.py`**(通用 SMTP 发送,告警/摘要等后续可复用):
```python
async def send_email(to: str, subject: str, html: str, text: str | None = None) -> None
async def send_verification_email(to: str, code: str, purpose: OtpPurpose) -> None # 模板封装
```
-`aiosmtplib` 异步发送
-`system_settings``smtp` 命名空间读配置host/port/username/password/from/use_tls
- 未配置抛 `EmailNotConfiguredError`
- 模板用简单 HTML + 纯文本双段,按 `purpose` 切换文案
编排("签码 → 发邮件")在 `api/v1/auth.py` 端点里调两个服务,不在 service 内互相调用,保持单测可单独 mock。
## 后端端点
新增到 `backend/app/api/v1/auth.py`
| 端点 | 入参 | 行为 |
| --- | --- | --- |
| `POST /auth/register` | `username, email, password` | 用户名/邮箱查重 → 写 User `is_active=True, email_verified=False, role="viewer"` → 调 `otp.issue_code(email, "register")` → 调 `email.send_verification_email` |
| `POST /auth/verify-email` | `email, code` | `otp.verify_code` → 置 `email_verified=True` → 直接返回 access/refresh token |
| `POST /auth/resend-code` | `email, purpose` | `check_resend_allowed``issue_code``send_verification_email` |
| `POST /auth/forgot-password` | `email` | 即便邮箱不存在也返回 200防枚举存在则签 `reset_password` 码并发邮件 |
| `POST /auth/reset-password` | `email, code, new_password` | `verify_code(..., "reset_password")``user.set_password(new_password)` |
`/auth/login` 改造:邮箱未验证用户登录返回 `403 { code: "EMAIL_NOT_VERIFIED", email }`,前端拿到后跳验证页。
## SMTP 设置
复用 `backend/app/api/v1/settings.py` 现有 setting store新增 `smtp` 命名空间:
- `smtp_host``smtp_port``smtp_username``smtp_password``smtp_from``smtp_from_name``smtp_use_tls`
- 密码走与 collector 凭证相同的加密路径(看 `backend/app/services/` 是否已有 `credentials_encryption` 之类工具,若有直接复用)
- `POST /settings/smtp/test` — 用当前未保存的入参试发一封到指定地址,不落库
未配置 SMTP 时 `/auth/register` 应返回明确错误 `503 { code: "EMAIL_PROVIDER_NOT_CONFIGURED" }`,提示管理员先去 `/settings` 配 SMTP 或用 `./planet.sh createuser` 兜底。
## 前端
**新页面**
- `frontend/src/pages/Register/Register.tsx` — 两步表单:(1) 用户名/邮箱/密码 (2) 6 位验证码60s 重发冷却;验证成功写 token自动跳 `/admin`
- `frontend/src/pages/VerifyEmail/VerifyEmail.tsx` — 给登录拦截 `EMAIL_NOT_VERIFIED` 时落地的页,仅"输码 + 重发"
- `frontend/src/pages/ForgotPassword/ForgotPassword.tsx` — 两步:(1) 输邮箱 (2) 输码 + 新密码
**改动**
- `frontend/src/pages/Login/Login.tsx` — 表单下加"注册账号"、"忘记密码"链接;接 `EMAIL_NOT_VERIFIED``/verify-email`
- `frontend/src/pages/Settings/Settings.tsx` — 新增 SMTP 子 tabhost/port/username/password/from/TLS + 测试发送按钮),用工作区里新建的 `frontend/src/components/ConnectionTestInput/` 做连通测试输入
- 路由表(`frontend/src/App.tsx``frontend/src/router/*`)— 加 `/register``/forgot-password``/verify-email`
## 关键文件清单
后端:
- `backend/app/models/user.py` — 加字段
- `backend/app/schemas/user.py` — 新增 `UserRegister``VerifyCode``ResetPasswordRequest` schema
- `backend/app/api/v1/auth.py` — 新端点 + 登录校验
- `backend/app/services/otp.py` *(新)*
- `backend/app/services/email.py` *(新)*
- `backend/app/api/v1/settings.py` — SMTP 命名空间 + 测试发送
- `backend/app/core/config.py` — SMTP 默认值/特性开关
- `backend/app/db/session.py` — DDL 兜底(若无 alembic
前端:
- `frontend/src/pages/Register/Register.tsx` *(新)*
- `frontend/src/pages/VerifyEmail/VerifyEmail.tsx` *(新)*
- `frontend/src/pages/ForgotPassword/ForgotPassword.tsx` *(新)*
- `frontend/src/pages/Login/Login.tsx`
- `frontend/src/pages/Settings/Settings.tsx`
- 路由文件
## 实施顺序
1. 后端User 模型字段 + DDL 兜底
2. 后端:`services/otp.py`(先纯单测跑通)
3. 后端:`services/email.py`(用 MailHog 本地试发)
4. 后端:`/auth/register` + `/auth/verify-email` + `/auth/resend-code` + 登录拦截
5. 后端:`/auth/forgot-password` + `/auth/reset-password`
6. 后端:`/settings/smtp` 命名空间 + 测试发送
7. 前端:`Settings.tsx` 加 SMTP 子 tab
8. 前端Register / VerifyEmail / ForgotPassword 页 + Login 入口
文档同步在 [文档受众分层重构计划](/home/ray/dev/linkong/planet/docs/plans/docs-audience-split-plan.md) 落地。
## 验证
- **后端单测**(仿 `backend/tests/test_settings_ai_provider.py`
- 注册端点用户名/邮箱查重
- OTP 过期、错码计数、重发限流
- 邮箱未验证用户登录返回 `EMAIL_NOT_VERIFIED`
- SMTP 未配置时注册端点返回 `EMAIL_PROVIDER_NOT_CONFIGURED`
- 忘记密码对不存在邮箱仍返回 200
- **后端集测**:本机起 MailHog 或 Mailtrap把 SMTP 指到上面,跑 register → 收码 → verify → login 一遍
- **前端**`source ~/.zshrc && bun run build`;启 dev server 走 `/register``/verify-email``/admin` 全流程,再试 `/forgot-password`
- **手测**:新邮箱注册 → 收码 → 输错 → 重发 → 输对 → 登录 → 改密码 → 用新密码再登;管理员在 `/settings` 改 SMTP → 测试发送

View File

@@ -309,17 +309,50 @@ AIS observations do not directly replace final vessel records. They are first sa
- Dynamic fields such as position, speed, and course are selected by freshness and source priority.
- Static fields prefer non-empty values; conflicting candidates are recorded for detail and diagnostics views.
Earth still reads vessel data from:
Earth vessel rendering now consumes the bounded snapshot endpoint and realtime delta channel:
```http
GET /api/v1/visualization/geo/vessels
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/visualization/vessels/{mmsi}
GET /api/v1/visualization/vessels/{mmsi}/track
GET /api/v1/visualization/vessels/{mmsi}/conflicts
GET /api/v1/visualization/vessels/aggregation/diagnostics
```
`/geo/vessels` merges raw observation aggregation with the legacy BarentsWatch latest-position tables so adding AISStream does not hide historical BarentsWatch-only vessels.
`/api/v1/vessels/snapshot` requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`. It reads only aggregated `ais_raw_observations`; it no longer merges legacy `vessel_position` / `vessel_static` rows. The old `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`.
Realtime deltas are sent through the `/ws` `vessels` channel. Clients must subscribe with the current viewport:
```json
{
"type": "subscribe",
"data": {
"channel": "vessels",
"bbox": [120.8, 30.7, 122.1, 31.8],
"zoom": 12,
"limit": 1000
}
}
```
The backend stores lightweight subscription filters per connection and only sends vessel updates that match the subscriber bbox. Collector broadcasts enter a 1-second throttle queue; within each flush window, only the latest update per MMSI is retained.
### Layer APIs And Global Stats
Earth is moving to two API families:
```http
GET /api/v1/data-products
GET /api/v1/data-products/{product_id}/status
GET /api/v1/layers/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/cables?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/landing-points?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/satellites?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/bgp/anomalies?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/bgp/incidents?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
```
`/api/v1/data-products/*` is for aggregate panels and keeps a global statistics scope independent of the map bbox. `/api/v1/layers/*` is for map rendering, requires `bbox` and `zoom`, defaults to `limit=1000`, and caps `limit` at `5000`; low zoom falls back to a smaller response cap and reports `degraded`, `truncated`, `limit_clamped`, and `stats_scope=viewport` in `diagnostics`. Non-vessel layers currently reuse the existing GeoJSON converters before the guard layer; future product-specific queries can push bbox filtering deeper.
## X. Collector Settings And Connectivity Validation
@@ -390,4 +423,12 @@ curl -X POST http://localhost:8000/api/v1/datasources/1/trigger \
-H "Authorization: Bearer <token>"
```
Batch collection uses:
```http
POST /api/v1/datasources/trigger-batch
```
The request body may pass `source_ids` for selected rows. Without `source_ids`, the backend filters by `product`, `module`, `is_active`, `run_status`, `collected`, `credential_status`, and `q`. The endpoint skips disabled sources, sources already running without `force`, and sources still inside their frequency window, then returns `triggered`, `skipped`, and `failed` groups.
**Core file**: `backend/app/api/v1/datasources.py`

View File

@@ -288,6 +288,16 @@ Normalization:
Connectivity validation reads saved configuration, environment variables, and `AISSTREAM_API_KEY` from `~/.zshrc`. For actual collection, prefer saving the API key in collector settings. If the key only lives in `~/.zshrc`, confirm that the backend process inherited it; otherwise validation may pass while the collector runtime cannot read the key.
Connectivity validation and actual collection are separate actions. A banner such as `AISStream credentials configured, WebSocket endpoint format valid` only means the saved settings can be used for a connection attempt; runtime status may still be `disconnected`. Global AIS data is written locally only while the `aisstream_vessels` collector is `streaming` / `connected` and its message count plus `last_seen_at` keep advancing.
The new vessel list entry point is no longer the legacy `/api/v1/visualization/geo/vessels` route. Earth initial state should call:
```http
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
```
That endpoint reads local aggregated `ais_raw_observations` only. Realtime updates use the `/ws` `vessels` channel; subscriptions must include `bbox`, `zoom`, and `limit`. The server filters updates per connection and merges collector broadcasts every second, keeping only the latest position per MMSI.
## Custom REST / WebSocket Mapping Runtime
Files:

View File

@@ -152,7 +152,11 @@ The `earth:compute-center-location-saved` reconciliation pipeline is deliberatel
### AIS Vessel Layer
The vessel layer fetches `/api/v1/visualization/geo/vessels` and renders the aggregated AIS GeoJSON through `createInteractableLayer()`. By default it does not send a `limit` parameter, and `VESSEL_CONFIG.maxRenderedMarkers = 0` means the frontend does not clip the result to 5000 vessels. A positive `options.limit` or positive `maxRenderedMarkers` can still be used as an explicit temporary cap.
The vessel layer now uses `/api/v1/vessels/snapshot` for the initial viewport snapshot and the `/ws` `vessels` channel for realtime deltas. Snapshot requests must include `bbox`, `zoom`, and a bounded `limit`; the backend defaults to `limit=1000` and caps it at `5000`. WebSocket subscriptions must include the same viewport fields so the server can filter updates per connection.
The legacy `/api/v1/visualization/geo/vessels` endpoint has been removed and returns `410 Gone`. Frontend code should fetch a snapshot for the current viewport when the layer opens, then subscribe to `vessels` deltas. After map pan or zoom, reload the snapshot and send a fresh vessels subscription. The backend no longer merges legacy `vessel_position` / `vessel_static` rows into vessel snapshots, so the frontend must not depend on old BarentsWatch-only fallback rows.
The new layer API family is `/api/v1/layers/*`, which separates map rendering payloads from aggregate panel statistics. Layer requests must include `bbox`, `zoom`, and a bounded `limit`; responses include `visible_count`, `returned_count`, and `diagnostics`, where `degraded`, `truncated`, and `limit_clamped` are the frontend signals for fallback UI. Right-side aggregate panels should not sum the layer response. They should read `/api/v1/data-products` or `/api/v1/data-products/{product_id}/status`, because those statistics stay global and do not change with the viewport.
Vessel color and vessel type text must use the same normalized classification. `vessels.js` derives `type` from both `vessel_type_name` and the AIS numeric `vessel_type` code; that `type` drives marker color. It also derives `vessel_type_display`, which `main.js` uses for the info card, hover summary, and search result subtitle. Do not make the info card read only the raw `vessel_type_name`, because AISStream can provide a numeric type while the raw name is still `Other`.

View File

@@ -163,7 +163,25 @@ Current constraints:
- Internal document links should be converted to `/docs/:slug` through `transformLink`
- Heading anchors are injected through `getHeadingId`, keeping route state outside the renderer
### 6. `TableActions`
### 6. `ConnectionTestInput`
File:
- [ConnectionTestInput.tsx](/home/ray/dev/linkong/planet/frontend/src/components/ConnectionTestInput/ConnectionTestInput.tsx)
Purpose:
- Console form fields that combine an endpoint/Base URL value with a connection check
- Connection-test entry points for AI Provider and WebSearch
- Future collector configuration fields should reuse it when the test action belongs inside the input
Current constraints:
- The input suffix shows a single plug/connector icon, not an adjacent text button
- Disabled integrations must grey out both the input and its connection-test action
- The component only combines the input and action; callers still own form state, loading, disabled state, and the request itself
### 7. `TableActions`
File:
@@ -205,10 +223,13 @@ File:
Responsibilities:
- `/ai` now owns LLM Provider, AI Tool configuration, and the testbench instead of nesting them under `/settings`
- The `模型供应商` tab manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test
- The `工具` tab manages WebSearch provider, search key, base URL, timeout, result count, and advanced provider options
- The `模型供应商` tab manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test; provider and model fields use editable comboboxes so users can manually enter new providers/models if the models.dev catalog stops updating
- The `工具` tab first selects a tool from a dropdown menu, then renders that tool's configuration; it currently includes WebSearch and OCR
- WebSearch configuration includes provider, search key, base URL, timeout, result count, and advanced provider options
- OCR configuration includes provider, Base URL, API key, model/engine, languages, timeout, file-size limit, and output format
- The `测试台` tab embeds the former Playground real session, preset prompts, and AI Provider status debugging
- The page reuses the Settings single-screen tabs, panel card, and internal scrolling style
- AI Provider and WebSearch connection tests use `ConnectionTestInput`, with the connector icon fixed at the end of the Base URL input; when WebSearch is disabled, every configuration field and the test entry point are greyed out except the switch
Legacy `/settings?tab=ai` should redirect to `/ai?tab=providers`.
Legacy `/playground` should redirect to `/ai?tab=playground`.

View File

@@ -1,666 +1,332 @@
# Planet Manual
This manual is for daily use, demos, development integration, and local operations. It covers four core entry points:
This manual is for Planet end users. Starting from the browser, it covers account registration, login, configuring collectors, configuring AI, using Earth and the console, and reading the docs site. Every action happens in a browser.
- `planet.sh`: local start, stop, restart, health check, and log access
- Earth: public 3D situational awareness page
- Console: admin backend (login required)
- Docs: backend Gatekeeper-controlled documentation; basic usage docs are public, while developer and operations docs require permission groups
For the shortest path to getting started, see [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md). For common troubleshooting, see the [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md).
If you are responsible for deployment or on-call duty, read the [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) instead — it covers shell commands, log paths, and CLI fallbacks for user creation.
## Entry Overview
After a default startup, the common URLs are:
| Name | URL | Login Required | Description |
| --- | --- | --- | --- |
| Earth | `http://localhost:3000/earth` | No | 3D globe, layers, BGP, satellites, cables, news situational awareness |
| Docs | `http://localhost:3000/docs` | Partly | Usage docs are public; developer, backend, and operations docs require Gatekeeper groups |
| FAQ | `http://localhost:3000/docs/faq` | No | Windows / WSL, ports, dependencies, motion capture, credentials, and permission troubleshooting |
| Console | `http://localhost:3000/admin` | Yes | Data, config, alerts, logs, and situational observation |
| AI | `http://localhost:3000/ai` | Yes | Model providers, AI tools, and testbench |
| Backend API Docs | `http://localhost:8000/docs` | Depends on endpoint | FastAPI / OpenAPI documentation |
| Earth | `http://<host>/earth` | No | Public 3D situational awareness page |
| Docs | `http://<host>/docs` | Partly | Public docs need no login; developer/ops docs need Gatekeeper groups |
| Register / Login / Forgot Password | `/register`, `/login`, `/forgot-password` | No | Self-serve account creation and recovery |
| Console | `http://<host>/admin` | Yes | Data, collectors, alerts, AI, users, settings |
| AI | `http://<host>/ai` | Yes | Model providers, tools, testbench |
| Backend API Docs | `http://<host>:8000/docs` | Depends | FastAPI / OpenAPI |
## planet.sh
URLs below use the local default `http://localhost:3000`. Replace the prefix with your deployment URL in production.
`planet.sh` is the main control script for local development and demos. Use it to manage services rather than manually starting frontend, backend, database, and AI Provider separately.
## Register an Account
### Start
1. Open `http://localhost:3000/login` and click "Register" under the form.
2. On `/register`, fill in:
- **Username**: 350 characters, used to log in
- **Email**: receives the verification code; editable later in account settings
- **Password**: at least 8 characters
3. After submission you are taken to the verify page. A 6-digit code is sent to your email. It expires in 10 minutes.
4. Enter the code and click "Verify and Sign In". On success the system stores a session and sends you to the console.
```bash
./planet.sh start
```
If no email arrives within 60 seconds:
Default behavior:
- Check spam, promotions, and any enterprise mail gateway
- The "Resend Code" button shows a 60-second countdown; you can resend after it ends
- After 5 wrong attempts the code is invalidated; you must resend a new one
- Starts PostgreSQL and Redis
- Starts AI Provider
- Starts the backend API
- Starts the frontend Vite dev server
- Outputs Earth, console, Playground, and backend API doc URLs
If you see "Email service not configured", the administrator has not yet set up SMTP. Ask the administrator to fill SMTP at `/settings -> SMTP Email`.
Specify custom ports:
The default role for a self-registered user is `viewer`, which can sign in and view public content. To see collector / user / settings pages, ask an `admin` or `super_admin` to promote your role at `/users`.
```bash
./planet.sh start -b 8001 -f 3001 -a 8101
```
## Sign In and Recover Password
Parameters:
### Sign In
| Flag | Meaning |
| --- | --- |
| `-b <port>` | Backend port |
| `-f <port>` | Frontend port |
| `-a <port>` | AI Provider port |
| `--allow-lan` | Enable LAN access |
| `--verbose` | Show more command output during execution |
Open `/login`, enter username and password. On success you are taken to `/admin`.
### AI Provider Environment and Builds
If you see "Email not verified", the page automatically redirects to `/verify-email` — follow the prompts to enter the code.
AI Provider runtime configuration can live in `aiprovider/.env` or in matching variables in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the container at startup.
### Forgot Password
Changing model, API key, or base URL does not rebuild the image. Restart only AI Provider to pick up runtime configuration changes:
1. On `/login`, click "Forgot Password?", or open `/forgot-password` directly.
2. Enter your registered email and click "Send Code". The same confirmation is shown regardless of whether the email is registered (to avoid enumeration).
3. After receiving the code, enter it together with a new password (≥ 8 characters) and click "Reset Password".
4. The system sends you back to `/login` — sign in with the new password.
```bash
./planet.sh restart -a
```
## Account Settings
For complex shell expansion in `~/.zshrc`, opt in explicitly:
Click your username at the top-right of the console to open account settings:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
- Change password: enter current password + new password
- Change email: the system sends a verification code to the new address; the change applies only after verification
- View Gatekeeper groups: lists current groups (`docs_user` / `docs_developer` / `docs_admin`)
- Log out: clears the current session
To ignore `~/.zshrc` during troubleshooting:
## Console Overview
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
The AI Provider Docker build context is intentionally limited to the files required by the service, and `uv sync` uses a BuildKit cache mount so dependency downloads are reused after the first build.
### Stop
```bash
./planet.sh stop
```
Stops:
- Backend
- AI Provider
- Frontend
- PostgreSQL
- Redis
### Restart
Full restart:
```bash
./planet.sh restart
```
Per-module restart:
```bash
./planet.sh restart -b
./planet.sh restart -f
./planet.sh restart -a
./planet.sh restart -d
```
| Flag | Effect |
| --- | --- |
| `-b` | Backend only |
| `-f` | Frontend only |
| `-a` | AI Provider only |
| `-d` | Database only |
Per-module restarts are preferred during development — they avoid interrupting unrelated services.
### Create User
```bash
./planet.sh createuser
```
Used to create a console login account before first use. The script interactively prompts for username, password, and role.
### Health Check
```bash
./planet.sh health
```
Checks:
- `planet_*` container status
- Backend `/health`
- AI Provider `/health`
- Frontend reachability
If something shows offline, check the corresponding logs first.
### Logs
Recent logs:
```bash
./planet.sh log
```
Follow logs:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
| Flag | Log source |
| --- | --- |
| `-f` / `--frontend` | `/tmp/planet_frontend.log` |
| `-b` / `--backend` | `/tmp/planet_backend.log` |
| `-a` / `--ai-provider` | `planet_aiprovider` container logs |
### LAN Access
```bash
./planet.sh start --allow-lan
```
Useful for:
- Starting in WSL, accessing from Windows browser
- Demos on phone or tablet
- Another machine on the same LAN accessing the same dev instance
`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but access from a phone or another computer through `http://<Windows LAN IP>:3000` still depends on Windows port forwarding and firewall rules.
Use this order to diagnose:
```bash
# From WSL or the shell running Planet
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
If this shows `0.0.0.0:3000` and `0.0.0.0:8000`, but the LAN IP still fails, configure Windows from an elevated PowerShell:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
## Earth
Earth is the public 3D situational awareness page, accessed at:
```text
http://localhost:3000/earth
```
It is a standalone frontend. The actual page lives at:
- `frontend/public/earth/index.html`
- `frontend/public/earth/js/`
- `frontend/public/earth/css/`
The React route `/earth` simply hosts it in an iframe.
### Main Uses
Earth is used to observe in a single globe view:
- BGP events, anomalies, and situational posture
- Satellites and orbital trails
- Submarine cables and landing points
- Compute centers
- AIS vessels
- Border lines, grid lines, HD texture, cloud layer, terrain
- Live news streams and situational news
- Search and focused object details
### Layer Control
The right-side layer panel toggles visualization layers on or off.
Common layers include:
- Grid lines
- Border lines
- HD texture
- Atmospheric cloud layer
- Submarine cables
- Compute centers
- BGP observation
- AIS vessels
- Satellites
- Orbital trails
- Terrain
Some layers have dependencies:
- Terrain requires HD texture
- Trails require Satellites
- When HD texture is off, the globe shows the base map and edge glow effect
### Legend
The lower-left legend follows the currently focused or enabled layer.
Current legend modes include:
- Cables
- Satellites
- Border lines
- Compute centers
- BGP
- AIS vessels
AIS vessel legend entries are grouped by vessel type: cargo, tanker, passenger, fishing, military, anchored/slow, and other. Triangle markers represent moving vessels; dots represent anchored or slow vessels.
### Search
Earth search finds current globe objects, such as:
- Submarine cables
- Landing points
- Satellites
- Compute centers
- BGP events
- BGP collectors
Search results can be used to quickly locate objects and open their details.
### Location Candidate Collection
Compute-center and BGP collector detail cards can collect candidate coordinates automatically. After clicking an object, use `自动采集坐标候选` or `重新自动采集坐标`; the backend ranks source coordinates, open organization lookups, and Nominatim online search results. If those regular sources return no candidates, the current default AI Provider is used once as an LLM factcheck fallback. Stored BGP collector locations are used as query context only and are not emitted as candidates.
Candidates can be previewed directly on Earth. Compute-center candidates can be saved into the `compute_center_locations` dimension table from the detail card, then the layer refreshes immediately. The notification badge on the compute-center layer row shows unresolved records that cannot be rendered; clicking it opens the queue, where users can collect individual candidates or use `一键采用` to save the highest-confidence candidate top-to-bottom. Records without candidates stay in the queue and are not replaced by country centroids or hard-coded hints. See [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md) for the full workflow.
### Settings
The settings panel contains:
- Rotation mode / cruise mode / motion mode
- Cruise modules: BGP, News, Compute Centers, Vessels, Cables, Satellites
- View settings: satellite display style, day/night mode, panel visibility
- Motion Debug Mode, Motion Input Source, skeleton-only debug view
- Globe default size
- Terrain opacity
- Reset settings
These settings are stored in browser local storage. They revert to defaults if you switch browsers or clear site data.
### View Controls
Earth supports mouse, touchpad, and touchscreen interaction.
Common controls:
| Action | Result |
| --- | --- |
| Left-button drag | Rotates the globe |
| One-finger drag | Rotates the globe on touch devices |
| Mouse wheel | Zooms the view in or out |
| Two-finger pinch | Zooms the view on touch devices |
| Zoom buttons | Adjust zoom in fixed steps |
| Click the zoom percent | Resets to the default zoom |
When zooming, the top capsule briefly shows the current zoom level, for example `Zoom 180%`. This indicates view zoom only, not data loading progress. Loading status takes priority and will not be interrupted by zoom feedback.
Drag sensitivity adjusts automatically based on the current zoom. Around the default view it keeps the normal rotation feel; when zoomed in, dragging becomes progressively finer for inspecting a region, vessel, satellite, or BGP event; when zoomed out, dragging is slightly faster for global browsing.
### Motion Capture Controls
Earth has a motion-capture control entry point for large-screen and future 3D displays. There are two realtime input sources: the default `Browser Camera` source uses webpage `getUserMedia` and recognizes gestures locally in the browser; the advanced `Motion Agent` source uses `camera/RTSP/HTTP -> local Agent -> local WebSocket -> Earth page`. Neither path sends camera frames or realtime gesture decisions to the cloud, and neither path reuses the news/RSS aggregation APIs.
It is disabled by default. Enable `Motion Debug Mode` in settings, open Earth with `?motion=1`, or set `planet-earth-motion-control-enabled=true` in browser local storage to start the selected source. The default source is `Browser Camera`; it requires HTTPS or localhost and a granted browser camera permission, but does not require installing an app. For dual cameras, USB indexes, phone/network camera streams, client integration, or edge devices, switch the setting to `Motion Agent`. The default Agent URL is `ws://127.0.0.1:8765/ws/gestures`; the `motionAgent` URL parameter can override it.
URL parameters can also force the source: `?motion=1&motionProvider=browser` uses the browser camera, `?motion=1&motionProvider=agent` uses Motion Agent, and providing `motionAgent=ws://...` automatically selects Motion Agent.
Current gesture semantics:
| Gesture event | Result |
| --- | --- |
| `rotate_left` | Rotates the globe left |
| `rotate_right` | Rotates the globe right |
| `rotate_up` | Rotates the globe upward |
| `rotate_down` | Rotates the globe downward |
| `zoom_in` | Zooms in |
| `zoom_out` | Zooms out |
| `focus_prev` / `focus_next` | Switches targets within the current motion layer |
| `layer_prev` / `layer_next` | Switches the motion candidate layer and cruises to the nearest target in that layer |
| `confirm` | Confirms the currently selected target; browser recognition currently keeps the two-hands-up confirm gesture disabled |
The settings panel also includes `Motion Debug Mode`, which opens the debug panel. With the Browser Camera source, the panel shows a local live preview and draws joints and bones over it. With the Motion Agent source, the Agent sends normalized skeleton events only and does not send raw video frames. The `Skeleton Only` switch hides the video preview and keeps the dark canvas plus skeleton; `Stop Matching Gestures` pauses gesture execution while preview and skeleton drawing can continue for debugging. Unmatched skeletons are red; once a gesture matches, the skeleton turns green and the matched gesture name is shown. Both this entry and the `Motion Input Source` control already carry Gatekeeper permission markers for future authorization control.
### Cruise Mode
Cruise mode makes Earth automatically cycle through focus targets.
Current cruise modules:
- BGP
- News
- Compute Centers
- Vessels
- Cables
- Satellites
Suitable for demos, monitoring displays, or unattended presentations.
### Mobile
Earth has a mobile drawer layout. On small screens:
- Layer controls open in a mobile drawer
- Search, settings, and details use mobile panels
- Main interactions remain centered on globe object clicks, search, and layer toggles
### Common Issues
#### Earth Won't Open
Check whether the frontend is online:
```bash
./planet.sh health
./planet.sh log -f
```
If the frontend port is not `3000`, use the actual port shown at startup.
#### Layer Has No Data
Check the backend and data sources:
```bash
./planet.sh health
./planet.sh log -b
```
Then open the console and check:
- `/datasources`
- `/data`
- `/bgp`
#### Satellites, BGP, or Cables Load Slowly
These layers may depend on backend APIs, external data sources, or first-run collection tasks. Wait for startup tasks to finish before checking logs and console data source status.
## Console
Console entry point:
```text
http://localhost:3000/admin
```
The console requires login. Create a user first if this is your first time:
```bash
./planet.sh createuser
```
### Page Structure
The console uses React + Ant Design, with a left-side menu organized by work domain.
Common pages:
The console at `http://localhost:3000/admin` is built with React + Ant Design. The left menu is organized by work domain.
| Page | Route | Purpose |
| --- | --- | --- |
| Dashboard | `/admin` | System overview |
| Earth | `/earth` | Opens the public Earth page |
| Data Sources | `/datasources` | View data sources and trigger collection |
| Collected Data | `/data` | View collected data |
| BGP Observation | `/bgp` | BGP situational data |
| Earth | `/earth` | Open the public Earth page |
| Datasources | `/datasources` | Source directory and collection triggers |
| Collected Data | `/data` | Data already ingested |
| BGP | `/bgp` | BGP situational view |
| System Alerts | `/alerts/system` | System-level alerts |
| BGP Alerts | `/alerts/bgp` | BGP-related alerts |
| Situational Alerts | `/alerts/situational` | Situational assessment alerts |
| AI | `/ai` | Model providers, WebSearch-style tools, and testbench |
| System Logs | `/logs` | View system logs (typically super admin only) |
| Users | `/users` | User management |
| Settings | `/settings` | System config and TV live stream sources |
| Situational Alerts | `/alerts/situational` | Situational analysis alerts |
| AI | `/ai` | Model providers, tools, testbench |
| Logs | `/logs` | Usually visible only to super admin |
| Users | `/users` | Create/delete users, change roles/groups |
| Settings | `/settings` | System, SMTP, TV, collectors |
### Data Sources
Menu items hide automatically when you lack permission. If a menu is missing, check your role and Gatekeeper groups.
`/datasources` shows collection sources and triggers collection. It is now a data source directory that lists built-in and custom sources in one table.
## Configure Data Collectors
Common operations:
`/settings?tab=collector_credentials` is the "Collector Settings" page. It manages connection configuration for every collector, not just credentials.
- View data source status
- Trigger collection
- View recent collection tasks
- Open the read-only detail drawer for endpoint, headers, runtime config, and built-in/custom source type
Steps:
If a category of objects is missing on Earth, start here to confirm the data source is available.
1. Pick a collector in the dropdown.
2. Inspect status tags:
- `No credentials` / `Credentials required`
- Owning module
- `Enabled` / `Disabled`
- `Unchecked` / `Reachable` / `Unreachable`
3. Click the plug icon next to the dropdown to run a health check. On success the status becomes `Reachable`.
4. Edit endpoint, headers, timeout, retries; click save.
The data source name opens an information drawer only. Endpoint, credentials, headers, and custom source configuration are maintained under `/settings` collector settings.
For free collectors without credentials, the health check hits the endpoint directly. For credential-bearing collectors it runs the credential flow. If credentials or endpoint changed since the last successful check, click connect again.
When collection tasks are running, the progress area shows a clickable `Collecting N` pill. Clicking it opens a modal with each running task's phase, progress, and processed count.
"Connected" means either: data was successfully collected with the current config, or the connect button passed validation with the current config.
### Collected Data
### BarentsWatch AIS Credentials
`/data` shows the collected data table.
Useful for diagnosing:
- Whether data has entered the system
- Whether data update times match expectations
- Whether a data source produced valid records
### BGP Observation
`/bgp` is the BGP-focused page.
It complements the BGP layer on Earth:
- Earth emphasizes spatial posture and visual focus
- The console BGP page emphasizes lists, status, details, and assessment
### Alerts
Alert entry points:
- `/alerts/system`
- `/alerts/bgp`
- `/alerts/situational`
Used to view system, network, and situational alerts.
### System Settings
`/settings` manages system-level configuration.
Current common uses:
- System settings
- TV live stream source configuration
- Collector settings
### AI
`/ai` manages the AI runtime chain and is now separate from system settings. Legacy `/playground` redirects to `/ai?tab=playground`.
It currently contains:
- `模型供应商`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, and connection test
- `工具`: WebSearch provider, search API key, base URL, max results, timeout, and advanced provider options
- `测试台`: AI Provider status, preset prompts, and real analysis-chain debugging
Legacy `/settings?tab=ai` redirects to `/ai?tab=providers`.
Available configuration depends on the current user's role.
#### Collector Settings
`/settings?tab=collector_credentials` is currently displayed as Collector Settings. It manages connection settings for all collectors, not only credentials.
Use it to:
1. Select a collector from the dropdown.
2. Review tags such as `Requires credentials`, module, enabled state, and `Unchecked` / `Available` / `Unavailable`.
3. Click the plug icon next to the selector to run a health check.
4. Edit endpoint, request headers, timeout, and retry settings.
5. Save the collector settings.
Free collectors are checked by requesting their endpoint directly. Credentialed collectors use their credential provider. If endpoint or credential fingerprint changes after the last successful validation, the collector must be checked again.
The system treats a collector as connected when the current configuration has either collected data successfully or passed the manual connection check.
#### BarentsWatch AIS Credentials
`BarentsWatch AIS` is a credentialed built-in collector. Its credential card appears above the basic configuration card.
Configured fields:
`BarentsWatch AIS` is a credential-required built-in collector. Selecting it surfaces the credential section above the base configuration:
- `Client ID`
- `Client Secret`
- `Endpoint`
If a secret is already configured, the input shows a masked preview. Keeping that preview unchanged preserves the stored secret; entering a new value replaces it.
If a secret was saved previously, the input shows a masked preview. Saving while keeping the preview unchanged preserves the original secret; entering a new secret overwrites it.
BarentsWatch AIS credentials can be read from:
When the connection fails, the page opens a credential guide. You can:
1. Collector settings saved in the console.
2. Backend environment variables:
- `BARENTSWATCH_CLIENT_ID`
- `BARENTSWATCH_CLIENT_SECRET`
- historical spellings: `BARRENTSWATCH_CLIENT_ID`, `BARRENTSWATCH_CLIENT_SECRET`
3. matching `export` lines in `~/.zshrc`.
- View the default guide
- Click "Guide not helpful" to ask AI Provider to regenerate from the default prompt
- Click "Reset" to restore the default guide
If connection fails, the page opens the credential guide. The guide can be regenerated through AI Provider or reset to the default guide. The default guide points users to the official BarentsWatch tutorial and emphasizes selecting `AIS - API`, not the regular `BarentsWatch - API`.
The default guide follows the BarentsWatch official tutorial and reminds you to choose `AIS - API` for Live AIS.
### System Logs
### AISStream Realtime Vessels
`/logs` views system logs. If the menu item is not visible, the current user likely lacks the required role.
`AISStream Realtime Vessels` is the global AIS WebSocket collector. A passing connection test only confirms API key + endpoint format. Actual global vessel data requires the backend `aisstream_vessels` collector to stay connected and write to `ais_raw_observations`.
Common troubleshooting sequence:
Steps:
```bash
./planet.sh health
./planet.sh log
```
1. Open `/settings?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
2. Fill the AISStream API Key
3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream`
4. Click the plug icon to test; confirm it reports `Reachable`
5. Save collector settings
6. Trigger the `aisstream_vessels` collector from the collection scheduler
7. Watch the `AISStream Runtime` panel:
- `streaming` / `connected` means the live stream is being consumed
- `messages this round` should keep growing
- `disconnected` with `ConnectionResetError` means the upstream or network dropped; re-trigger or wait for reconnect
Then open `/logs` for more structured runtime information.
## Configure AI Credentials
## Docs
`/ai?tab=providers` is the AI management entry. Two key sub-tabs:
Documentation site:
- `Model Providers`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, connection test
- `Tools`: a dropdown for specific tools — currently WebSearch and OCR
```text
http://localhost:3000/docs
```
### Model Providers
Docs content is read through backend APIs by permission. The frontend no longer bundles all Markdown files directly. Source files still live in:
Providers and models accept presets or arbitrary custom IDs. Common fields:
```text
docs/technical/zh/ (Chinese)
docs/technical/en/ (English)
```
- Provider: e.g. `minimax`, `openai`, `anthropic`, `ollama`
- Protocol: `OpenAI Chat Completions` / `Anthropic Messages` / `Ollama Generate`
- Base URL: model API URL
- Default Model: e.g. `gpt-5.1`, `MiniMax-M2.7`
- API Key: stored on save; displayed masked afterwards
- Max Tokens, Anthropic Version: keep defaults if unsure
- Timeout / Retry: timeout and retry attempts
Anonymous visitors only see `public` docs such as the overview, quickstart, and manual. Logged-in users can see more technical docs when assigned Gatekeeper groups:
The plug icon at the end of the Base URL input runs a connection test. A passing test echoes the model's short reply.
- `docs_user`: user-operation docs.
- `docs_developer`: Earth, frontend, backend, collector, and AI Provider development docs.
- `docs_admin`: service control, operations, environment variable, and sensitive-operation docs.
### Tools
`admin` receives admin-doc access by default, and `super_admin` can read all Docs content. Gatekeeper groups are configured in the console Users page.
- **WebSearch**: provider, API key, base URL, max results, timeout, advanced provider parameters. While disabled, all fields except the enable switch are greyed out
- **OCR**: provider, base URL, API key, model/engine, recognition languages, timeout, max file size, output format
Docs supports:
The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
- Category navigation
- Markdown rendering
- Tables and code blocks
- In-document table of contents
- Search across currently visible docs
- Internal links between technical documents
## System Settings
When adding a new technical document, check:
`/settings` manages system-level configuration. Sub-tabs:
- Does it have a clear top-level heading
- Does it need to be added to backend Docs metadata for category and ordering
- Should it be classified as `public`, `docs_user`, `docs_developer`, or `docs_admin`
- **System Display**: name, refresh interval, retention, max concurrent tasks
- **Notifications**: alert email switch, recipient, critical/warning/daily summary
- **Security**: session timeout, max login attempts, password policy
- **SMTP Email**: outgoing email used by registration and password reset (visible to `admin` / `super_admin` only)
- **TV Livestream**: TV source management
- **AI / WebSearch / OCR**: see above
## Development Command Conventions
### SMTP Email Settings
Frontend commands must use Bun:
Public registration and verification codes depend on this section. An `admin` or `super_admin` opens `/settings -> SMTP Email` and fills:
```bash
cd frontend
bun install
bun run dev
bun run build
```
- SMTP host, port
- Username, password
- From address (required), from name
- STARTTLS (typical for port 587) or implicit TLS (port 465)
- Timeout in seconds
Do not use `npm run ...`. The project uses Bun in WSL / Windows mixed environments to avoid Node/npm path compatibility issues.
Save, then click "Send Test Email" and enter a recipient address to verify delivery. Once that works, regular users can self-register at `/register`.
Verify the frontend build:
Leaving the masked password preview unchanged keeps the original password. Enter a new value to replace it.
```bash
source ~/.zshrc && bun run build
```
## User Management (Admins)
## Troubleshooting Order
`/users` is `super_admin`-only for create/delete. The page supports:
When something goes wrong, follow this sequence:
- Listing users (username, email, role, active, email verified)
- Creating users (equivalent to public registration but skips email verification — administrator vouching)
- Changing roles: `viewer` / `operator` / `admin` / `super_admin`
- Editing Gatekeeper groups: `docs_user` / `docs_developer` / `docs_admin`, controlling which docs are visible
- Disabling / enabling accounts
1. Check service status:
To let a regular user read developer or operations docs, add `docs_developer` or `docs_admin` at `/users`.
```bash
./planet.sh health
```
## Data Exploration
2. Check recent logs:
- `/datasources`: source directory. It can be filtered by product domain, layer/module, enabled state, last run status, whether collected records exist, and search text. Selecting rows triggers only those sources; with no selected rows, `Collect current filter` triggers the filtered scope. Clicking a name opens an info drawer showing endpoint, headers, base config, and built-in flag; endpoint/credentials editing happens at `/settings -> Collector Settings`. The `Collecting N` tag under the overall progress can be clicked to expand the current collection task list
- `/data`: collected data table — used to verify "did data arrive", "is the freshness right", "does a source emit valid records"
- `/bgp`: BGP detail page with list + detail + analysis; complements the BGP layer on Earth
- `/alerts/system`, `/alerts/bgp`, `/alerts/situational`: system, BGP, and situational alerts
```bash
./planet.sh log
```
## AI Testbench
3. Check per-module logs:
`/ai?tab=playground` is for real-pipeline debugging:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
- Pick the active provider
- Run preset requests or custom prompts
- Watch AI Provider status and response
4. Restart only the affected module:
The legacy link `/playground` redirects here.
```bash
./planet.sh restart -f
./planet.sh restart -b
./planet.sh restart -a
```
## Earth Public Page
5. If database or cache is abnormal, restart the database:
Earth at `http://localhost:3000/earth` is the public 3D situational page; no login required. The React route `/earth` wraps a standalone frontend (in `frontend/public/earth/`) via iframe.
```bash
./planet.sh restart -d
```
### Primary Uses
6. If still unrecovered, do a full restart:
A single globe view of: BGP events and observations, satellites and tracks, cables and landing points, compute centers, country boundaries / graticules / high-res tiles / clouds / terrain, news live streams and situational news, search and focus details.
```bash
./planet.sh restart
```
### Layer Control
The right-side layer panel toggles layers. Common layers: graticule, country boundaries, high-res tiles, atmospheric clouds, cables, compute centers, BGP, satellites, AIS vessels, tracks, terrain.
Dependencies:
- Terrain depends on high-res tiles
- Tracks depend on satellites
- With high-res tiles disabled, the globe shows the base map with edge highlighting
### Legend
The bottom-left legend follows the focused or enabled layer. Covered: cables, satellites, country boundaries, compute centers, BGP, AIS vessels.
AIS vessel legend colors by type: cargo, tanker, passenger, fishing, military, moored/slow, other. Triangles indicate moving vessels; dots indicate moored or slow targets.
### Search
Search finds cables, landing points, satellites, compute centers, BGP events, BGP observers. Results jump to and focus the object.
### Coordinate Candidate Collection
Compute center and BGP observer detail cards support automatic coordinate-candidate collection. Click the object then use "Collect Coordinate Candidates" or "Re-collect Coordinates". The backend assembles candidates from source coordinates, public-org registry APIs, and online geocoders. When regular sources have no candidate, the current default AI Provider runs one LLM factcheck fallback. BGP observers' stored coordinates only fill query context; they are not returned as candidates.
Candidates preview on Earth directly. Saving a compute-center candidate writes to the `compute_center_locations` dimension table and refreshes the layer immediately. The notification badge at the top-left of the compute-center layer shows the unresolved count; clicking it opens the queue, supports single collection, or "Adopt All" to save the top-confidence candidates from top to bottom. Records without candidates stay in the queue rather than being faked to country centroids. See [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md).
### Settings
The settings panel covers: rotate / cruise / motion mode, cruise modules (BGP/news/compute centers/vessels/cables/satellites), view (satellite display style, day-night mode, panel toggles), motion debug mode / input source / skeleton-only, default globe size, terrain opacity, reset.
These settings live in browser local storage; switching browsers or clearing site data resets them.
### View Controls
| Action | Effect |
| --- | --- |
| Mouse drag | Rotate the globe |
| Single-finger drag | Touch rotate |
| Mouse wheel | Zoom in/out |
| Pinch | Touch zoom |
| Zoom button | Stepped zoom |
| Click zoom percentage | Reset to default zoom |
A small pill at the top briefly shows the current zoom while zooming. This is not a data loading indicator; if data is loading, the loading state takes precedence.
Drag sensitivity adjusts to zoom: near the default it is normal; zoomed in it is finer for targeted inspection; zoomed out it is slightly faster for global browsing.
### Motion Capture
Earth supports motion capture. Two live inputs:
- **Browser Camera** (default): uses `getUserMedia` in the page. No install needed but the page must run on HTTPS or localhost, and the user must grant camera permission
- **Motion Agent**: camera/RTSP/HTTP → local agent → local WebSocket → Earth. Used for dual cameras, USB index, phone/IP camera streams
Enable via the settings toggle "Motion Debug Mode", or with URL parameter `?motion=1`. Motion Agent defaults to `ws://127.0.0.1:8765/ws/gestures`; override with `motionAgent`. You can also pin the input with `?motion=1&motionProvider=browser` or `?motion=1&motionProvider=agent`.
Neither mode uploads camera frames or live gestures; neither reuses the news/RSS aggregation API.
Gesture semantics:
| Event | Effect |
| --- | --- |
| `rotate_left/right/up/down` | Rotate accordingly |
| `zoom_in/out` | Zoom |
| `focus_prev/next` | Cycle focusable targets within the current layer |
| `layer_prev/next` | Switch layer and pan to nearest target |
| `confirm` | Confirm the current selection |
In the debug panel: the browser camera input shows the live preview with skeleton overlay; Motion Agent sends only normalized skeleton events, never raw frames. "Skeleton Only" hides the video and keeps just the skeleton; "Stop Matching" pauses gesture firing while keeping preview and skeleton. Unmatched skeleton is red; matched turns green and shows the action name.
### Cruise Mode
Cruise mode auto-rotates focused targets. Current modules: BGP, news, compute centers, vessels, cables, satellites. Suitable for demos, control rooms, and unattended displays.
### Mobile
Mobile uses a drawer layout: layer control moves into a drawer; search/settings/details use mobile panels. Main interaction is still object tap, search, and layer toggles.
### Common Issues
- **Earth does not open**: confirm the frontend is online; if not on port `3000`, use the port printed by the startup log
- **Layers have no data**: open `/datasources` to check source status, collected-record state, and the latest run result; then `/data` or `/bgp` for records
- **Satellites / BGP / cables load slowly**: those layers depend on backend APIs and external data sources; the first load waits for startup tasks
## Docs Site
Docs at `http://localhost:3000/docs` are served by the backend with access control, not bundled into the frontend build.
Anonymous visitors see only `public` docs: README, Quickstart, Manual, FAQ, Earth Location Candidate Collection User Guide. Authenticated users with Gatekeeper groups see more:
- `docs_user`: end-user operational docs
- `docs_developer`: Earth, frontend, backend, collectors, AI Provider development docs
- `docs_admin`: service control, operations, environment variables, sensitive operations (including the Ops Runbook)
`admin` has `docs_admin` by default; `super_admin` has all docs permissions. Gatekeeper groups are managed at `/users`.
Docs supports: category navigation, Markdown rendering, tables and code blocks, in-doc table of contents, search across currently visible docs, internal links between technical docs.
## Related Docs
- [Quickstart](/home/ray/dev/linkong/planet/docs/technical/en/quickstart.md)
- [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
- [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- [Earth Layer Style Reference](/home/ray/dev/linkong/planet/docs/technical/en/earth-layer-style-reference.md)
- [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md)
- [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md)
- [System Service Control](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md)
- [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
- [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md)

View File

@@ -0,0 +1,244 @@
# Planet Ops Runbook
This runbook is for deployment, on-call, and maintenance engineers. End-user UI flows live in the [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md); this document only covers shell, Docker, logs, environment variables, and troubleshooting.
## First Startup
```bash
./planet.sh start
```
Default behavior:
- Starts PostgreSQL and Redis
- Starts AI Provider
- Starts the backend API
- Starts the frontend Vite dev server
- Prints Earth, console, Playground, and backend API doc URLs
First startup seeds two default accounts (see `DEFAULT_LOGIN_USERS` in `backend/app/db/session.py`):
| Username | Password | Role |
| --- | --- | --- |
| `admin` | `admin123` | `super_admin` |
| `linkong` | `12345678` | `super_admin` |
Both seed accounts are created with `email_verified = TRUE` and can log into the console immediately. Any other account must either go through the public registration flow described in the Manual, or be created via `./planet.sh createuser`.
Specify custom ports:
```bash
./planet.sh start -b 8001 -f 3001 -a 8101
```
| Flag | Meaning |
| --- | --- |
| `-b <port>` | Backend port |
| `-f <port>` | Frontend port |
| `-a <port>` | AI Provider port |
| `--allow-lan` | Enable LAN access |
| `--verbose` | Show extra command output |
## Stop and Per-Module Restart
Stop everything:
```bash
./planet.sh stop
```
Stops backend, AI Provider, frontend, PostgreSQL, Redis.
Per-module restart:
```bash
./planet.sh restart # full
./planet.sh restart -b # backend
./planet.sh restart -f # frontend
./planet.sh restart -a # AI Provider
./planet.sh restart -d # database
```
Per-module restart is preferred during development to avoid interrupting unrelated services.
## Health Check
```bash
./planet.sh health
```
Checks:
- `planet_*` container status
- Backend `/health`
- AI Provider `/health`
- Frontend reachability
If anything reports offline, check the corresponding logs first.
## Logs
Recent logs:
```bash
./planet.sh log
```
Follow:
```bash
./planet.sh log -f # frontend: /tmp/planet_frontend.log
./planet.sh log -b # backend: /tmp/planet_backend.log
./planet.sh log -a # AI Provider: planet_aiprovider container logs
```
## CLI User Creation
```bash
./planet.sh createuser
```
Interactively prompts for username, password, and role; writes the user with `email_verified = TRUE` directly.
Use when:
- SMTP is not yet configured but an admin account is needed now
- Pre-seeding internal test accounts
- Public registration is unavailable for any reason and a fallback is required
For ordinary user onboarding, configure SMTP at `/settings -> SMTP Email` first and let users self-register at `/register`.
## LAN / WSL Access
```bash
./planet.sh start --allow-lan
```
Useful for:
- Starting in WSL, accessing from Windows browser
- Demoing Earth from a phone or tablet
- Other LAN machines reaching the same dev instance
`--allow-lan` only makes the frontend and backend listen on `0.0.0.0`. When Planet runs in WSL, Windows can usually reach it through `localhost`, but other LAN machines hitting `http://<Windows LAN IP>:3000` still need Windows port forwarding and firewall rules.
Diagnose in this order:
```bash
# From the shell running Planet
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
If WSL shows `0.0.0.0:3000` / `0.0.0.0:8000` but the LAN IP still fails, configure Windows from an elevated PowerShell:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
## AI Provider Environment and Builds
AI Provider runtime configuration lives in two places:
| Location | Best for | Notes |
| --- | --- | --- |
| `aiprovider/.env` | Team-shared local defaults | Read by Docker Compose as `env_file` |
| `~/.zshrc` | Personal provider/model/key/proxy | `planet.sh` reads common `AI_*`, `SERVICE_*`, `PYTHON_IMAGE`, `UV_IMAGE` lines |
Recommended form:
```bash
export AI_PROVIDER=minimax
export AI_PROVIDER_API=anthropic-messages
export AI_BASE_URL=https://api.example.com/anthropic
export AI_API_KEY=sk-change-me
export AI_MODEL=MiniMax-M2.7
export AI_PROVIDER_SERVICE_TOKEN=change_me
```
By default `planet.sh` only statically parses simple `export KEY=value` lines from `~/.zshrc`. When complex shell expansion is required, opt in explicitly:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
To ignore `~/.zshrc` entirely:
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
The AI Provider image only rebuilds when code, Dockerfile, Compose config, or Python dependencies change. After changing keys or base URL, restarting the container is enough:
```bash
./planet.sh restart -a
```
Diagnose slow builds:
| Symptom | Common cause | Fix |
| --- | --- | --- |
| Large `transferring context` | build context includes unrelated frontend / data files | `.dockerignore` ships only required files |
| `uv sync` is slow | first build or cold cache | wait for the first build; later runs reuse BuildKit cache |
| Old keys still in effect after edit | container not restarted | `./planet.sh restart -a` |
## SMTP Email (Required for Public Registration)
Public registration and email verification depend on SMTP. Administrators configure host, port, username, password, from-address, and TLS mode at `/settings -> SMTP Email` in the console, then use the "Send Test Email" button to verify. Settings are persisted in the `system_settings.smtp` row.
When SMTP is unset, `POST /api/v1/auth/register` returns `503 EMAIL_PROVIDER_NOT_CONFIGURED` and the frontend surfaces a clear error. The operational fallback is `./planet.sh createuser`.
One-time codes are stored in Redis under `otp:{purpose}:{email}` with a 600-second TTL. The key is invalidated after 5 invalid attempts. Resend cooldown is 60 seconds, enforced via `otp_rate:{purpose}:{email}`.
## Troubleshooting Order
```bash
./planet.sh health # 1. service state
./planet.sh log # 2. recent logs
./planet.sh log -f # 3. per-module logs
./planet.sh log -b
./planet.sh log -a
./planet.sh restart -f # 4. restart only the affected module
./planet.sh restart -b
./planet.sh restart -a
./planet.sh restart -d # 5. database / cache issues
./planet.sh restart # 6. full restart if still broken
```
## Development Command Conventions
Frontend must use Bun:
```bash
cd frontend
bun install
bun run dev
bun run build
```
Do not use `npm run ...`. In the WSL / Windows mixed environment Bun avoids Node/npm path inconsistencies.
Validate the frontend build:
```bash
source ~/.zshrc && bun run build
```
Backend dependencies are managed with uv:
```bash
uv sync
uv run pytest backend/tests/test_otp_service.py
```
## Related Docs
- [planet.sh Startup Mechanism](/home/ray/dev/linkong/planet/docs/technical/en/ops-planet-sh-startup.md)
- [System Service Control](/home/ray/dev/linkong/planet/docs/technical/en/backend-system-service-control.md)
- [Docker + Compose + Buildx Upgrade](/home/ray/dev/linkong/planet/docs/technical/en/ops-docker-compose-buildx-upgrade.md)
- [Data Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)

View File

@@ -1,236 +1,66 @@
# Quickstart
This guide is for developers or demo operators starting Planet for the first time. The goal is to get services running via the shortest path and know which URLs to open.
This quickstart is for Planet end users who just received an access URL and need the shortest path from "open the browser" to "first useful configuration done". Every action happens in the browser.
If you run into port conflicts, Windows / WSL LAN access, `uv` / `bun`, camera, or Docs permission issues, start with the [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md).
If you are responsible for deployment or operations, read the [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md) instead.
## Prerequisites
## 1. Open the URL
Recommended: run in a WSL / Linux shell.
Open the URL your administrator gave you, e.g. `http://planet.example.com`. A local demo is usually `http://localhost:3000`.
You need:
Entry points are split in two:
- Docker / Docker Compose available
- `uv` and `bun` accessible in the current shell
- Repository cloned locally
- Public: `/earth` (3D situational view), `/docs` (public documentation)
- Login required: `/admin` (console), `/ai` (AI), `/settings` (system configuration)
On a new machine, run the bootstrap script first:
## 2. Register
```bash
./scripts/bootstrap-dev.sh
```
1. Open `/login` and click "Register" under the form.
2. On `/register`, fill in username, email, password (≥ 8 characters).
3. After submission, check your inbox for a 6-digit verification code (valid for 10 minutes).
4. Enter the code on the verify page and click "Verify and Sign In". You are taken to the console automatically.
This script checks and syncs common dependencies, and generates if missing:
If the email does not arrive:
- `backend/.env`
- `aiprovider/.env`
- `frontend/.env.local`
- Check spam and your enterprise mail gateway
- The "Resend Code" button has a countdown; you can resend once it ends
- A "Email service not configured" message means your administrator has not yet set up SMTP — please ping them
Personal AI Provider configuration can also live in `~/.zshrc`. `planet.sh` reads simple `export AI_...=...` / `AI_...=...` lines and passes them to the AI Provider container. After changing model, key, or base URL, restart only AI Provider:
The default role is `viewer`: you can sign in but only see public pages. For collectors, user management, or system settings, ask the admin to promote your role or add Gatekeeper groups.
```bash
./planet.sh restart -a
```
## 3. First Sign-In Checklist
Collector credentials such as AISStream and BarentsWatch can also start in `~/.zshrc` for connectivity validation:
After landing on the `/admin` dashboard, here's a recommended walk-through:
```bash
export AISSTREAM_API_KEY="..."
export BARENTSWATCH_CLIENT_ID="..."
export BARENTSWATCH_CLIENT_SECRET="..."
```
1. `/settings?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
3. `/datasources` or `/data`: check whether the collectors have produced data
4. `/alerts/system`: verify system alerts look right
5. `/users` (super_admin only): open accounts for teammates or adjust their groups
For actual collection, prefer saving credentials in `Settings -> Collector Settings`, especially for AISStream's long-lived WebSocket collector. That keeps connectivity validation, backend collection tasks, and Earth realtime vessel aggregation on the same configuration source.
## 4. Open Earth
## 1. Start Services
From the repository root:
```bash
./planet.sh start
```
After startup, the key URLs are:
| Entry | Default URL | Purpose |
| --- | --- | --- |
| Earth | `http://localhost:3000/earth` | Public 3D Earth visualization |
| Console | `http://localhost:3000/admin` | Admin console (login required) |
| Docs | `http://localhost:3000/docs` | Usage docs are public; developer and operations docs require Gatekeeper groups |
| AI | `http://localhost:3000/ai` | Model provider, tool, and testbench entry (login required) |
| Backend API Docs | `http://localhost:8000/docs` | FastAPI / OpenAPI interface docs |
If the default ports are taken, specify custom ports:
```bash
./planet.sh start -f 3001 -b 8001 -a 8101
```
If backend port `8000` is occupied by a Windows listener or an old portproxy rule, follow the [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md) troubleshooting order.
## 2. Create a Login User
The console requires login. For first-time use:
```bash
./planet.sh createuser
```
Follow the prompts to enter username, password, and role.
To read developer or operations docs, log in as `super_admin` and assign Gatekeeper groups from the Users page. Use `docs_developer` for development docs and `docs_admin` for service-control and operations docs.
## 3. Open Earth
Visit:
```text
http://localhost:3000/earth
```
Earth is a public page — no login required.
Visit `/earth`. This is a public page — no login required.
Once in, verify:
- The globe renders correctly
- The right-side layer panel can toggle layers on/off
- Search can find cables, satellites, compute centers, BGP events
- Compute-center and BGP collector detail cards can collect and preview coordinate candidates; when regular sources have no candidate, the current default AI Provider runs one LLM factcheck fallback; the compute-center unresolved badge can open the queue and save candidates
- Mouse drag, wheel zoom, and zoom percent feedback work correctly
- Settings panel can switch rotate / cruise / motion mode, day/night mode, and satellite display style; Motion Debug Mode can show the local Browser Camera preview plus skeleton overlay
- The globe renders, and the right-side layer panel can toggle layers
- Search finds cables, satellites, compute centers, BGP events
- Compute-center and BGP collector detail cards can collect coordinate candidates and preview them on Earth
- Mouse drag, wheel zoom, and the zoom percentage indicator work
- The settings panel can switch rotate / cruise / motion modes
## 4. Open the Console
## 5. Recover a Lost Password
Visit:
Open `/forgot-password`, enter your email, receive a code, then enter the code plus a new password. The same confirmation is shown for unknown emails (to avoid enumeration).
```text
http://localhost:3000/admin
```
## 6. Read the Docs
The console manages data sources, collected data, situational observation, alerts, system logs, and configuration.
First-time inspection checklist:
- `/datasources`: data source directory and collection triggers; endpoint, headers, and credentials are configured under `/settings` collector settings
- `/data`: collected data
- `/bgp`: BGP situational view
- `/ai`: AI page for model providers, WebSearch-style tools, and the testbench
- `/alerts/system`: system alerts
- `/settings`: system configuration
## 5. Check Service Health
```bash
./planet.sh health
```
This shows container status and checks:
- Backend
- AI Provider
- Frontend
## 6. View Logs
Recent logs:
```bash
./planet.sh log
```
Follow a specific service:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
Flags:
- `-f`: frontend logs
- `-b`: backend logs
- `-a`: AI Provider logs
## 7. Common Restarts
Frontend only:
```bash
./planet.sh restart -f
```
Backend only:
```bash
./planet.sh restart -b
```
AI Provider only:
```bash
./planet.sh restart -a
```
Database only:
```bash
./planet.sh restart -d
```
Full restart:
```bash
./planet.sh restart
```
## 8. LAN Access
To allow a Windows browser, phone, or another device on the same network:
```bash
./planet.sh start --allow-lan
```
This makes the frontend and backend listen on a LAN-accessible address.
Note: `--allow-lan` only makes Planet listen on `0.0.0.0`; it does not automatically expose WSL services through the Windows LAN IP. A common pattern is:
- `localhost:3000` / `localhost:8000` works inside WSL
- `localhost:3000` / `localhost:8000` works on Windows
- `http://<Windows LAN IP>:3000` fails from a phone or another computer
That usually means Windows still needs port forwarding or firewall rules.
If access fails, check from the shell running Planet:
```bash
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
If WSL is listening on `0.0.0.0:3000` and `0.0.0.0:8000` but the LAN IP still fails, configure Windows forwarding and firewall rules from an elevated PowerShell:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
## 9. Stop Services
```bash
./planet.sh stop
```
This shuts down the frontend, backend, AI Provider, PostgreSQL, and Redis.
`/docs` is the docs site. Without login you can read: this Quickstart, the Manual, the FAQ. Authenticated users with `docs_user` / `docs_developer` / `docs_admin` groups see additional technical documents.
## Next Steps
- Full usage guide: [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md)
- Console structure: [Admin Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/frontend-admin-frontend-context.md)
- Earth structure: [Earth Frontend Context](/home/ray/dev/linkong/planet/docs/technical/en/earth-frontend-context.md)
- Backend collectors: [Backend Collectors](/home/ray/dev/linkong/planet/docs/technical/en/backend-collectors.md)
- Full UI walkthrough: [Planet Manual](/home/ray/dev/linkong/planet/docs/technical/en/manual.md)
- Troubleshooting and configuration questions: [FAQ](/home/ray/dev/linkong/planet/docs/technical/en/faq.md)
- Detailed Earth coordinate candidate flow: [Earth Location Candidate Collection User Guide](/home/ray/dev/linkong/planet/docs/technical/en/location-pipeline-user.md)
- Deployment / operations commands: [Planet Ops Runbook](/home/ray/dev/linkong/planet/docs/technical/en/ops-runbook.md)

View File

@@ -336,17 +336,50 @@ AIS 观测写入后不会直接替换最终船只记录,而是先保存为 raw
- 位置、速度、航向等动态字段会按 freshness 和来源优先级选择。
- 静态字段优先保留非空值;冲突候选会记录到详情接口,便于排查多源差异。
Earth 使用的接口仍是
Earth 船只展示现在使用受控快照接口和实时增量通道
```http
GET /api/v1/visualization/geo/vessels
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/visualization/vessels/{mmsi}
GET /api/v1/visualization/vessels/{mmsi}/track
GET /api/v1/visualization/vessels/{mmsi}/conflicts
GET /api/v1/visualization/vessels/aggregation/diagnostics
```
`/geo/vessels` 会合并 raw observation 聚合结果 legacy BarentsWatch latest position 结果,避免只接入 AISStream 后把历史 BarentsWatch 船只遮蔽掉
`/api/v1/vessels/snapshot` 必须携带 `bbox``zoom`,默认 `limit=1000`,最大 `limit=5000`。它只消费 `ais_raw_observations` 聚合结果,不再读取 legacy `vessel_position` / `vessel_static` 作为兜底。旧 `/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`
实时增量通过 `/ws``vessels` channel 推送。客户端订阅时必须带当前视口:
```json
{
"type": "subscribe",
"data": {
"channel": "vessels",
"bbox": [120.8, 30.7, 122.1, 31.8],
"zoom": 12,
"limit": 1000
}
}
```
后端按连接保存轻量订阅条件,只向 bbox 命中的连接发送船只更新。collector 广播会先进入 1 秒节流队列,同一 MMSI 在一个 flush 周期内只保留最新位置,避免高频实时流拖垮 WebSocket。
### 图层接口与全量统计分离
Earth 后续迁移到两类接口:
```http
GET /api/v1/data-products
GET /api/v1/data-products/{product_id}/status
GET /api/v1/layers/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/cables?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/landing-points?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/satellites?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/bgp/anomalies?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/bgp/incidents?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
GET /api/v1/layers/bgp/collectors?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
```
`/api/v1/data-products/*` 面向聚合面板,统计口径是全量/全局,不受地图 bbox 影响。`/api/v1/layers/*` 面向地图渲染,必须携带 `bbox``zoom`,默认 `limit=1000`,最大 `limit=5000`;低 zoom 会降级到更小的返回上限,并在 `diagnostics` 中暴露 `degraded``truncated``limit_clamped``stats_scope=viewport`。当前版本的非船只图层先复用已有 GeoJSON 转换再做保护层,后续可继续把 bbox 下推到各产品专用查询。
## 十、采集器设置与连接验证
@@ -418,4 +451,12 @@ curl -X POST http://localhost:8000/api/v1/datasources/1/trigger \
-H "Authorization: Bearer <token>"
```
批量采集使用:
```http
POST /api/v1/datasources/trigger-batch
```
请求体可以传 `source_ids` 精确触发选中项;如果不传 `source_ids`,后端按 `product``module``is_active``run_status``collected``credential_status``q` 过滤后触发。接口会跳过禁用源、正在运行且未 `force` 的源,以及未到频率窗口的源,并返回 `triggered``skipped``failed` 三组结果。
**核心文件**: `backend/app/api/v1/datasources.py`

View File

@@ -290,6 +290,16 @@ AISStream 使用 WebSocket 实时流,采集器只写入 `ais_raw_observations`
连接验证会读取保存配置、环境变量和 `~/.zshrc` 中的 `AISSTREAM_API_KEY`。正式采集时,推荐把 API Key 保存到采集器设置;如果只写在 `~/.zshrc`,需要确认后端进程实际继承了该变量,否则连接验证可能可用但 collector 运行时拿不到 key。
连接验证和正式采集是两个不同动作。设置页出现 `AISStream 凭证已配置WebSocket endpoint 格式有效` 只说明配置可以用于连接;运行状态仍可能是 `disconnected`。只有 `aisstream_vessels` collector 任务处于 `streaming` / `connected`,并且 `本轮消息数``last_seen_at` 持续更新时,全球 AIS 数据才会不断写入本地库。
新版本不再使用 legacy `/api/v1/visualization/geo/vessels` 作为船只列表入口。Earth 初始状态应调用:
```http
GET /api/v1/vessels/snapshot?bbox=lon_min,lat_min,lon_max,lat_max&zoom=12&limit=1000
```
该接口只查询本地 `ais_raw_observations` 聚合结果。实时更新走 `/ws``vessels` channel订阅时必须提供 `bbox``zoom``limit`。服务端按连接过滤 bbox并对 collector 广播做 1 秒合并,同一 MMSI 只推送最新位置。
## 自定义 REST / WebSocket 映射运行时
文件:

View File

@@ -286,8 +286,9 @@ AIS 船只图层入口:
船只图层当前负责:
- 请求 `/api/v1/visualization/geo/vessels`
- 将聚合后的 AIS GeoJSON 转为地球局部坐标 marker 数据;请求默认不传 `limit`,后端和前端都不再默认裁剪到 5000 艘
- 请求 `/api/v1/vessels/snapshot` 获取当前视口初始快照;请求必须携带 `bbox``zoom`,并传入受控 `limit`
- 通过 `/ws``vessels` channel 订阅后续增量;订阅 payload 同样必须携带当前视口 `bbox``zoom``limit`
- 将聚合后的 AIS GeoJSON 转为地球局部坐标 marker 数据;后端默认 `limit=1000`,最大 `limit=5000`
- 通过 `createInteractableLayer()` 注册 Interactable 图标层
- 用按航向分桶的 `THREE.Points` 批量渲染普通船只 marker
- 按船型映射颜色;`vessels.js` 会用 `vessel_type_name` 和 AIS `vessel_type` 数字共同归一化船型
@@ -310,6 +311,10 @@ AIS 船只图层入口:
AISStream 的 `PositionReport` 常带实时位置和 `MetaData.ShipName`,但船型通常来自低频 `ShipStaticData.Type`。后端会把 `MetaData.ShipName` 补进船名,并将类型码映射为 Cargo / Tanker / Passenger / Fishing / Military仍缺失的船型需要等待静态 AIS 消息或后续船舶资料 enrichment不能在前端凭颜色之外的信息臆造细分类。
`/api/v1/visualization/geo/vessels` 已下线并返回 `410 Gone`。前端打开船只图层时应先按当前视口拉一次 `/api/v1/vessels/snapshot`,再用 WebSocket 接收同一视口内的 upsert 增量;地图拖动或缩放后应重新拉取 snapshot 并重发 vessels 订阅。后端不再把 legacy `vessel_position` / `vessel_static` 合并进船只快照,前端也不应依赖旧表里的 BarentsWatch-only 兜底数据。
新的图层接口族是 `/api/v1/layers/*`,用于把地图渲染数据和聚合面板统计分开。地图层请求必须带 `bbox``zoom` 和受控 `limit`,响应会返回 `visible_count``returned_count``diagnostics`,其中 `degraded/truncated/limit_clamped` 用于前端提示降级。右侧聚合统计不要从图层响应累加,应读取 `/api/v1/data-products``/api/v1/data-products/{product_id}/status`,因为这些统计保持全量/全局口径,不随当前视口变化。
船只 hover / click 也不再对渲染对象做 `raycaster.intersectObjects()``main.js` 只负责传入当前 Earth、camera、pointer 和命中半径,实际命中计算由 `interactable.js` 的图标层接口完成:
1. 拖动地球或惯性旋转时跳过 hover picking。

View File

@@ -163,7 +163,25 @@
- 文档内部链接应通过 `transformLink` 转成 `/docs/:slug`
- 标题锚点由 `getHeadingId` 注入,避免渲染器自己理解路由状态
### 6. `TableActions`
### 6. `ConnectionTestInput`
文件:
- [ConnectionTestInput.tsx](/home/ray/dev/linkong/planet/frontend/src/components/ConnectionTestInput/ConnectionTestInput.tsx)
用途:
- Endpoint、Base URL 这类“输入值 + 连接验证”的控制台表单项
- AI Provider 和 WebSearch 的连接测试入口
- 后续采集器配置如果把连接测试放进输入框,也应复用它
当前约束:
- 输入框末端只显示一个插头/连接器图标,不再并排放“测试连接”文字按钮
- 禁用的集成能力必须同时置灰输入框和连接测试按钮
- 组件只负责输入框与测试入口组合不保存业务状态调用方仍负责表单值、loading、disabled 和连接请求
### 7. `TableActions`
文件:
@@ -205,10 +223,13 @@
职责:
- `/ai` 独立承载 LLM Provider、AI Tool 配置和测试台,不再放在 `/settings` 的系统配置 tabs 中
- `模型供应商` tab 管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试
- `工具` tab 管理 WebSearch provider、搜索 key、base URL、超时、结果数和高级 provider 参数
- `模型供应商` tab 管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试provider 和模型输入使用可输入组合框models.dev 目录停更时用户仍可手动填新 provider/model
- `工具` tab 先通过下拉菜单选择工具,再管理对应配置;当前包含 WebSearch 和 OCR
- WebSearch 配置包含 provider、搜索 key、base URL、超时、结果数和高级 provider 参数
- OCR 配置包含 provider、Base URL、API Key、模型/engine、语言、超时、文件大小上限和输出格式
- `测试台` tab 嵌入原 Playground 的真实会话、预设请求和 AI Provider 状态调试
- 页面复用 Settings 的单屏 tabs、panel card 和内部滚动样式
- AI Provider / WebSearch 的连接测试使用 `ConnectionTestInput`,连接器图标固定在 Base URL 输入框末端WebSearch 未启用时,除开关外的配置项和测试入口都置灰
旧的 `/settings?tab=ai` 应跳转到 `/ai?tab=providers`
旧的 `/playground` 应跳转到 `/ai?tab=playground`

View File

@@ -1,543 +1,92 @@
# Planet 使用手册
这份手册面向日常使用、演示、开发联调和本地运维。它覆盖四个核心入口:
这份手册面向 Planet 的最终用户。从打开浏览器开始,覆盖注册账号、登录、配置数据采集器、配置 AI、使用 Earth 和控制台、阅读文档站。所有操作都在浏览器里完成。
- `planet.sh`:本地启动、停止、重启、健康检查和日志入口
- Earth公开 3D 地球态势页面
- 控制台:登录后的管理后台
- Docs后端 Gatekeeper 受控的文档站,基础使用文档公开,开发/运维文档按权限组开放
快速启动路径见 [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)。常见排障见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
如果你是负责部署或值班的运维,请改读 [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md),里面是 shell 命令、日志位置、SMTP 兜底创建用户等内容。
## 入口总览
默认启动后,常用地址如下:
| 名称 | 地址 | 是否需要登录 | 说明 |
| --- | --- | --- | --- |
| Earth | `http://localhost:3000/earth` | 否 | 3D 地球、图层、BGP、卫星、海缆、新闻态势 |
| Docs | `http://localhost:3000/docs` | 部分需要 | 使用手册公开;开发、后端、运维文档按 Gatekeeper 权限组开放 |
| FAQ | `http://localhost:3000/docs/faq` | 否 | Windows / WSL、端口、依赖、动捕、凭证和权限排障 |
| 控制台 | `http://localhost:3000/admin` | 是 | 数据、配置、告警、日志和专题观测 |
| AI | `http://localhost:3000/ai` | 是 | 模型供应商、AI 工具和测试台 |
| 后端 API 文档 | `http://localhost:8000/docs` | 视接口而定 | FastAPI / OpenAPI 文档 |
| Earth | `http://<域名>/earth` | 否 | 公开 3D 地球态势页面 |
| Docs | `http://<域名>/docs` | 部分需要 | 公共文档免登录,开发/运维文档按 Gatekeeper 权限组开放 |
| 注册 / 登录 / 找回密码 | `/register``/login``/forgot-password` | 否 | 自助开通和恢复账号 |
| 控制台 | `http://<域名>/admin` | 是 | 数据、采集器、告警、AI、用户、设置 |
| AI | `http://<域名>/ai` | 是 | 模型供应商、工具和测试台 |
| 后端 API 文档 | `http://<域名>:8000/docs` | 视接口而定 | FastAPI / OpenAPI |
## planet.sh
下面所有 URL 都基于本机演示的默认地址 `http://localhost:3000`,部署到正式环境时把前缀换成你们的访问域名即可。
`planet.sh` 是本地开发和演示的主控脚本。优先使用它管理服务,而不是手动分别启动前端、后端、数据库和 AI Provider。
## 注册账号
### 启动
1. 打开 `http://localhost:3000/login`,点击表单下方"注册账户"。
2.`/register` 填写:
- **用户名**350 位字符,登录时使用
- **邮箱**:用于接收验证码,可在账户设置中修改
- **密码**:至少 8 位
3. 提交后会跳到验证页,已将 6 位验证码发到你的邮箱。10 分钟内有效。
4. 输入验证码,点击"验证并登录"。验证通过后系统会自动写入登录态并跳到控制台。
```bash
./planet.sh start
```
如果 60 秒内没收到邮件:
默认行为:
- 检查垃圾邮件、订阅推广、企业邮件网关
- 验证页右下角的"重新发送验证码"会显示 60 秒倒计时,倒计时结束后可重发
- 连续输错 5 次后该验证码会失效,需要重发新码
- 启动 PostgreSQL 和 Redis
- 启动 AI Provider
- 启动后端 API
- 启动前端 Vite dev server
- 输出 Earth、控制台、Playground 和后端 API 文档入口
如果系统提示"邮件服务尚未配置",说明管理员还没填 SMTP请联系管理员开通 SMTP 或在控制台 `/settings -> SMTP 邮件` 完成配置。
可指定端口:
默认注册角色为 `viewer`,可以登录控制台浏览公共内容。要看采集器、用户管理、系统设置等管理类页面,需要 `admin``super_admin``/users` 给你升角色。
```bash
./planet.sh start -b 8001 -f 3001 -a 8101
```
## 登录与找回密码
参数含义:
### 登录
| 参数 | 含义 |
| --- | --- |
| `-b <port>` | 后端端口 |
| `-f <port>` | 前端端口 |
| `-a <port>` | AI Provider 端口 |
| `--allow-lan` | 允许局域网访问 |
| `--verbose` | 在执行过程中显示更多命令输出 |
打开 `/login`,输入用户名和密码即可。登录成功后跳到 `/admin`
### AI Provider 环境变量和构建
如果提示"邮箱未验证",页面会自动跳到 `/verify-email`,按提示输入验证码完成验证。
AI Provider 的运行期配置可以放在两处:
### 忘记密码
| 位置 | 适合内容 | 说明 |
| --- | --- | --- |
| `aiprovider/.env` | 团队约定的本地默认配置 | Docker Compose 会作为 `env_file` 读取 |
| `~/.zshrc` | 个人机器上的 provider、模型、密钥和代理变量 | `planet.sh` 启动时会读取常见的 `AI_*``SERVICE_*``PYTHON_IMAGE``UV_IMAGE`、代理变量 |
1.`/login` 点击"忘记密码?",或直接打开 `/forgot-password`
2. 输入注册邮箱,点击"发送验证码"。无论邮箱是否注册,页面都会显示同一句提示(避免账号枚举)。
3. 收到验证码后,在下一步填入验证码 + 新密码(至少 8 位),点击"重置密码"。
4. 系统会跳回 `/login`,用新密码登录即可。
推荐写法:
## 账户设置
```bash
export AI_PROVIDER=minimax
export AI_PROVIDER_API=anthropic-messages
export AI_BASE_URL=https://api.example.com/anthropic
export AI_API_KEY=sk-change-me
export AI_MODEL=MiniMax-M2.7
export AI_PROVIDER_SERVICE_TOKEN=change_me
```
控制台右上角点击你的用户名进入账户设置,可以:
默认情况下,`planet.sh` 只静态解析 `~/.zshrc` 中简单的 `export KEY=value``KEY=value` 行,避免 shell 主题、插件或交互初始化拖慢启动。如果变量依赖复杂 shell 展开,可以显式启用 source 模式:
- 修改密码:输入当前密码 + 新密码
- 修改邮箱:输入新邮箱后系统会发验证码到新地址,验证通过后才生效
- 查看权限组:列出你目前拥有的 Gatekeeper 权限组(`docs_user` / `docs_developer` / `docs_admin`
- 登出:清除当前会话
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
## 控制台总览
如需完全忽略 `~/.zshrc`
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
AI Provider 镜像只在代码、Dockerfile、Compose 配置或相关 Python 依赖变化时重建。修改 `aiprovider/.env``~/.zshrc` 中的模型、密钥、Base URL 不会触发镜像重建;重启 AI Provider 即可让容器读取新配置:
```bash
./planet.sh restart -a
```
构建较慢时,优先判断当前卡在哪一层:
| 现象 | 常见原因 | 处理方式 |
| --- | --- | --- |
| `transferring context` 很大 | Docker build context 包含前端资源、PDF、数据目录等无关文件 | 当前仓库通过 `.dockerignore` 只发送 AI Provider 必需文件 |
| `uv sync` 下载依赖较慢 | 首次构建或缓存为空,网络访问 Python 包较慢 | 等待首次构建完成;后续会复用 BuildKit 的 uv 下载缓存 |
| 改密钥后仍显示旧配置 | 容器尚未重启 | 执行 `./planet.sh restart -a` |
### 停止
```bash
./planet.sh stop
```
会停止:
- 后端
- AI Provider
- 前端
- PostgreSQL
- Redis
### 重启
全量重启:
```bash
./planet.sh restart
```
按模块重启:
```bash
./planet.sh restart -b
./planet.sh restart -f
./planet.sh restart -a
./planet.sh restart -d
```
| 参数 | 作用 |
| --- | --- |
| `-b` | 只重启后端 |
| `-f` | 只重启前端 |
| `-a` | 只重启 AI Provider |
| `-d` | 只重启数据库 |
按模块重启适合日常开发,能避免无关服务被打断。
### 创建用户
```bash
./planet.sh createuser
```
用于首次进入控制台前创建登录账号。脚本会交互式提示用户名、密码和角色。
### 健康检查
```bash
./planet.sh health
```
会检查:
- `planet_*` 容器状态
- 后端 `/health`
- AI Provider `/health`
- 前端页面可达性
如果某项显示 offline优先查看对应日志。
### 日志
最近日志:
```bash
./planet.sh log
```
持续跟随日志:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
| 参数 | 日志来源 |
| --- | --- |
| `-f` / `--frontend` | `/tmp/planet_frontend.log` |
| `-b` / `--backend` | `/tmp/planet_backend.log` |
| `-a` / `--ai-provider` | `planet_aiprovider` 容器日志 |
### 局域网访问
```bash
./planet.sh start --allow-lan
```
适合:
- WSL 中启动Windows 浏览器访问
- 手机或平板演示 Earth
- 局域网其他机器访问同一个开发实例
`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。如果服务运行在 WSL 中Windows 本机通常可以通过 `localhost` 访问,但手机或其他电脑访问 `http://<Windows局域网IP>:3000` 还依赖 Windows 端口转发和防火墙放行。
推荐按顺序判断:
```bash
# 在 WSL 或运行 Planet 的 shell 中
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
如果这里能看到 `0.0.0.0:3000``0.0.0.0:8000`,但局域网 IP 访问失败,请在管理员 PowerShell 中配置:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
## Earth
Earth 是公开的 3D 态势页面,入口:
```text
http://localhost:3000/earth
```
它是独立前端,实际页面位于:
- `frontend/public/earth/index.html`
- `frontend/public/earth/js/`
- `frontend/public/earth/css/`
React 路由中的 `/earth` 只是用 iframe 承载它。
### 主要用途
Earth 用于在一个地球视图中观察:
- BGP 事件、异常和观测态势
- 卫星和轨迹
- 海缆与登陆点
- 算力中心
- 国界线、经纬线、高清材质、云图、地形
- 新闻直播和态势新闻
- 搜索和聚焦对象详情
### 图层控制
右侧图层面板用于打开或关闭可视图层。
常见图层包括:
- 经纬线
- 国界线
- 高清材质
- 大气云图
- 海缆
- 算力中心
- BGP 观测
- 卫星
- AIS 船只
- 轨迹
- 地形
部分图层存在依赖关系:
- 地形依赖高清材质
- 轨迹依赖卫星
- 高清材质关闭时,地球会显示基座地图和边缘识别效果
### 图例
左下角图例会跟随当前聚焦或启用的图层切换。
当前已覆盖:
- 海缆
- 卫星
- 国界线
- 算力中心
- BGP
- AIS 船只
AIS 船只图例按船型显示颜色:
- 货轮
- 油轮
- 客船
- 渔船
- 军舰
- 停泊/低速
- 其他船只
船只图例中的三角形对应地图上的航行船只标记,圆点对应停泊或低速状态。
### 搜索
Earth 搜索支持查找当前地球对象,例如:
- 海缆
- 登陆点
- 卫星
- 算力中心
- BGP 事件
- BGP 观测站
搜索结果可以用于快速定位对象,并打开对应详情。
### 位置候选采集
算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后,使用详情卡中的 `自动采集坐标候选``重新自动采集坐标` 按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选位置;这些常规来源没有候选时,会使用当前默认 AI Provider 做一次 LLM factcheck 兜底。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。
候选可以直接在 Earth 上预览。算力中心候选点击 `保存` 后会写入 `compute_center_locations` 维表,并立即刷新图层。算力中心图层左上角的通知气泡显示无法渲染的待定位数量;点击后可查看列表,单条采集候选,或用 `一键采用` 从上到下保存最高置信候选。没有可用候选的记录会留在列表中,不会被国家中心点或硬编码 hint 伪造位置。详细流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md)。
### 设置
设置面板包含:
- 旋转模式 / 巡航模式 / 动捕模式
- 巡航模块BGP、新闻、算力中心、船只、海缆、卫星
- 视图设置:卫星显示风格、日夜模式、面板显示开关
- 动捕调试模式、动捕输入源、只显示骨骼
- 地球默认大小
- 地形透明度
- 重置设置
这些设置会保存在浏览器本地存储中。换浏览器或清理站点数据后会恢复默认值。
### 视角控制
Earth 支持鼠标、触控板和触屏操作。
常用控制方式:
| 操作 | 作用 |
| --- | --- |
| 鼠标左键拖动 | 旋转地球 |
| 手指单指拖动 | 在触屏设备上旋转地球 |
| 鼠标滚轮 | 放大或缩小视角 |
| 双指捏合 | 在触屏设备上放大或缩小视角 |
| 缩放按钮 | 按固定步长调整缩放 |
| 点击缩放百分比 | 重置到默认缩放 |
缩放时,顶部胶囊会短暂显示当前缩放比例,例如 `缩放 180%`。这个提示只表示当前视角缩放,不代表数据加载进度;如果页面正在加载数据,加载提示优先显示,缩放提示不会打断加载状态。
拖动灵敏度会根据当前缩放自动调整。默认视角附近保持常规旋转速度;放大后拖动会逐步变细,适合检查某个区域、船只、卫星或 BGP 事件;缩小后拖动会略快,方便快速浏览全球态势。
### 动作捕捉控制
Earth 预留了动作捕捉控制入口,面向大屏和未来 3D 展示。实时链路有两种输入源:默认的 `浏览器摄像头` 会直接用网页 `getUserMedia` 在本机浏览器识别;高级的 `Motion Agent` 会走 `摄像头/RTSP/HTTP -> 本地 Agent -> 本地 WebSocket -> Earth 页面`。两种模式都不会把摄像头帧或实时手势发到云端,也不会复用新闻/RSS 聚合接口。
默认不自动启用。打开设置里的 `动捕调试模式`,或用 `?motion=1` 打开 Earth 动捕连接后,系统会启动当前选择的输入源。输入源默认是 `浏览器摄像头`,无需安装应用,但页面必须运行在 HTTPS 或 localhost且用户需要允许浏览器摄像头权限。需要双摄、USB index、手机/网络摄像头流或客户端/边缘设备时,可在设置中切到 `Motion Agent`。默认 Agent 地址是 `ws://127.0.0.1:8765/ws/gestures`,也可用 URL 参数 `motionAgent` 覆盖。
URL 参数也可以直接指定输入源:`?motion=1&motionProvider=browser` 使用浏览器摄像头;`?motion=1&motionProvider=agent` 使用 Motion Agent传入 `motionAgent=ws://...` 时会自动选择 Motion Agent。
当前手势语义:
| 手势事件 | 作用 |
| --- | --- |
| `rotate_left` | 地球向左旋转 |
| `rotate_right` | 地球向右旋转 |
| `rotate_up` | 地球向上旋转 |
| `rotate_down` | 地球向下旋转 |
| `zoom_in` | 放大视角 |
| `zoom_out` | 缩小视角 |
| `focus_prev` / `focus_next` | 在当前动捕图层内切换可交互目标 |
| `layer_prev` / `layer_next` | 切换动捕候选图层,并巡航到新图层最近目标 |
| `confirm` | 确认当前已选目标;当前浏览器识别暂未启用双手上举确认 |
设置面板中的 `动捕调试模式` 会打开调试面板。浏览器摄像头输入源会在面板内显示本机实时预览,并在其上绘制关节点和连线;`Motion Agent` 输入源只发送归一化骨架事件,不发送原始视频帧。面板里的 `只显示骨骼` 会隐藏视频预览、只保留深色背景和骨架;`停止匹配动作` 会暂停手势触发,但摄像头预览和骨架绘制仍可继续用于调试。未匹配动作时骨架为红色,匹配后变绿并显示当前动作名称。该入口和 `动捕输入源` 控件都已预留 Gatekeeper 权限标记,后续可接入鉴权控制。
### 巡航模式
巡航模式会让 Earth 自动轮播聚焦目标。
当前巡航模块包括:
- BGP
- 新闻
- 算力中心
- 船只
- 海缆
- 卫星
适合演示、监控大屏或无人值守展示。
### 移动端
Earth 有移动端抽屉布局。小屏下:
- 图层控制进入移动抽屉
- 搜索、设置、详情会使用移动端面板
- 主要交互仍围绕地球对象点击、搜索和图层开关
### 常见问题
#### Earth 打不开
先检查前端是否在线:
```bash
./planet.sh health
./planet.sh log -f
```
如果前端端口不是 `3000`,使用启动时输出的实际端口。
#### 图层没有数据
检查后端和数据源:
```bash
./planet.sh health
./planet.sh log -b
```
然后进入控制台查看:
- `/datasources`
- `/data`
- `/bgp`
#### 卫星、BGP 或海缆加载慢
这些图层可能依赖后端接口、外部数据源或首次加载任务。先等待启动任务完成,再查看日志和控制台数据源状态。
## 控制台
控制台入口:
```text
http://localhost:3000/admin
```
控制台需要登录。首次使用先创建用户:
```bash
./planet.sh createuser
```
### 页面结构
控制台使用 React + Ant Design左侧菜单按工作域组织。
常见入口:
控制台 `http://localhost:3000/admin` 使用 React + Ant Design左侧菜单按工作域组织。
| 页面 | 路由 | 用途 |
| --- | --- | --- |
| 仪表盘 | `/admin` | 系统概览 |
| Earth | `/earth` | 打开公开 Earth 页面 |
| 数据源 | `/datasources` | 查看数据源触发采集 |
| 采集数据 | `/data` | 查看采集后的数据 |
| BGP 观测 | `/bgp` | 查看 BGP 专题数据 |
| Earth | `/earth` | 跳到公开 Earth 页面 |
| 数据源 | `/datasources` | 数据源目录、触发采集 |
| 采集数据 | `/data` | 已落库的数据 |
| BGP 观测 | `/bgp` | BGP 专题观测 |
| 系统告警 | `/alerts/system` | 系统级告警 |
| BGP 告警 | `/alerts/bgp` | BGP 相关告警 |
| 态势告警 | `/alerts/situational` | 态势研判告警 |
| AI | `/ai` | 模型供应商、WebSearch 等工具测试台 |
| 系统日志 | `/logs` | 查看系统日志,通常仅 super admin 可见 |
| 用户管理 | `/users` | 管理用户 |
| 系统配置 | `/settings` | 系统配置和电视直播源等设置 |
| AI | `/ai` | 模型供应商、工具测试台 |
| 系统日志 | `/logs` | 通常仅 super admin 可见 |
| 用户管理 | `/users` | 创建/删除/改角色/调权限组 |
| 系统配置 | `/settings` | 系统、SMTP、TV、采集器设置 |
### 数据源
权限不足时菜单项会自动隐藏。如果发现某个菜单看不到,先确认自己的角色和 Gatekeeper 权限组。
`/datasources` 用于查看采集来源和触发采集。当前页面是“数据源目录”,会把内置数据源和自定义数据源放在同一张列表里展示。
## 配置数据采集器
常见操作:
`/settings?tab=collector_credentials` 是"采集器设置"页。这里统一维护所有采集器的连接配置,不仅是凭证。
- 查看数据源状态
- 触发采集
- 查看最近采集任务
- 打开详情抽屉查看 endpoint、请求头、基础配置和是否为内置数据源
如果 Earth 上某类对象缺失,通常先到这里确认数据源是否可用。
数据源列表中的名称点击后只打开信息抽屉,不再承担编辑入口。接口地址、凭证、请求头和自定义数据源配置统一到 `/settings` 的“采集器设置”里维护。
当有采集任务正在运行时,总体进度下方会出现 `采集中 N` 标签。这个标签和其他状态标签放在同一排,但带有可点击样式;点击后会弹出当前采集中任务列表,显示每个任务的阶段、进度和处理数量。
### 采集数据
`/data` 用于查看采集后的数据表。
适合排查:
- 数据是否已经进入系统
- 数据更新时间是否符合预期
- 某个数据源是否产出了有效记录
### BGP 观测
`/bgp` 是 BGP 专题页面。
它和 Earth 的 BGP 图层互补:
- Earth 强调空间态势和可视聚焦
- 控制台 BGP 页面强调列表、状态、详情和研判
### 告警
告警入口包括:
- `/alerts/system`
- `/alerts/bgp`
- `/alerts/situational`
用于查看系统、网络和态势相关告警。
### 系统配置
`/settings` 用于管理系统级配置。
当前常见用途包括:
- 系统设置
- 电视直播源配置
- 采集器设置
### AI
`/ai` 用于管理 AI 运行链路,已经从系统配置中独立出来。旧链接 `/playground` 会跳转到 `/ai?tab=playground`
当前包含:
- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试
- `工具`WebSearch provider、搜索 API Key、Base URL、最大结果数、超时和高级 provider 参数
- `测试台`AI Provider 状态、预设请求和真实分析链路调试
旧链接 `/settings?tab=ai` 会跳转到 `/ai?tab=providers`
具体可用配置取决于当前登录用户权限。
#### 采集器设置
`/settings?tab=collector_credentials` 当前显示为“采集器设置”。这里统一维护所有采集器的连接配置,而不是只维护凭证。
使用方式:
操作步骤:
1. 在下拉框选择采集器。
2. 查看状态标签:
@@ -545,167 +94,242 @@ http://localhost:3000/admin
- 所属模块
- `启用` / `禁用`
- `未检查` / `可用` / `不可用`
3. 点击下拉框右侧的插头图标执行健康检查。
4. 如果检查通过,状态会变为 `可用`
5. 修改 endpoint、请求头、超时或重试次数后保存。
3. 点击下拉框右侧的插头图标执行健康检查。检查通过状态变为 `可用`
4. 修改 endpoint、请求头、超时、重试次数等然后保存
对于免费且不需要凭证的采集器,连接检查直接请求对应 endpoint对于需要凭证的采集器,连接检查会走对应凭证链路如果凭证或 endpoint 相比上次验证成功时发生变化,需要重新点击连接。
对于免费且不需要凭证的采集器,连接检查直接 endpoint对于需要凭证的采集器走对应凭证链路如果凭证或 endpoint 相比上次验证成功时发生变化,需要重新点击连接。
系统判断已连接的条件
系统判断"已连接"的条件:
- 当前配置已经成功采集过数据
- 当前配置已经点击过连接按钮并验证成功
- 当前配置已经成功采集过数据
- 当前配置已经点击过连接按钮并验证成功
#### BarentsWatch AIS 凭证
### BarentsWatch AIS 凭证
`BarentsWatch AIS` 是需要凭证的内置采集器。选择该采集器后,凭证区域显示在基础配置上方
配置项:
`BarentsWatch AIS` 是需要凭证的内置采集器。选择该采集器后,凭证区域显示在基础配置上方
- `Client ID`
- `Client Secret`
- `Endpoint`
如果已配置过 secret输入框会显示脱敏预览。保存时如果保持这个脱敏预览不变,系统会保留原 secret只有输入新的 secret 才会替换。
BarentsWatch AIS 支持从以下位置读取凭证:
1. 控制台采集器设置中保存的凭证。
2. 后端环境变量:
- `BARENTSWATCH_CLIENT_ID`
- `BARENTSWATCH_CLIENT_SECRET`
- 兼容历史拼写:`BARRENTSWATCH_CLIENT_ID``BARRENTSWATCH_CLIENT_SECRET`
3. `~/.zshrc` 中的同名 `export`
如果已配置过 secret输入框会显示脱敏预览。保存时保持脱敏预览不变会保留原 secret只有输入新的 secret 才会替换。
如果连接失败,页面会弹出凭证获取教程。教程支持:
- 查看默认教程
- 点击教程不好用让 AI Provider 根据默认 prompt 重新生成教程。
- 点击重置恢复默认教程
- 查看默认教程
- 点击"教程不好用"让 AI Provider 默认 prompt 重新生成
- 点击"重置"恢复默认教程
默认教程以 BarentsWatch 官方 tutorial 为准,并提醒 Live AIS 应选择 `AIS - API`,不是普通 `BarentsWatch - API`
默认教程以 BarentsWatch 官方 tutorial 为准,并提醒 Live AIS 应选择 `AIS - API`
### 系统日志
### AISStream 实时船舶
`/logs` 用于查看系统日志。若菜单中不可见,通常是当前用户角色没有权限
`AISStream 实时船舶` 是全球 AIS WebSocket 采集器。连接测试通过只说明 API Key 和 endpoint 格式可用;真正的全球船只数据来自后台 `aisstream_vessels` collector 长连接运行并写入 `ais_raw_observations`
排查问题时常用组合
操作步骤
```bash
./planet.sh health
./planet.sh log
```
1. `/settings?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
2.`AISStream 凭证` 填入 API Key
3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream`
4. 点击插头图标进行连接测试,确认显示 `可用`
5. 保存采集器设置
6. 到采集调度入口运行 `aisstream_vessels` collector
7.`AISStream 运行状态` 中观察:
- `streaming` / `connected` 表示正在接收实时流
- `本轮消息数` 应持续增长
- 如果显示 `disconnected` 且错误为 `ConnectionResetError`,需要重新触发或等待重连
再进入 `/logs` 查看更结构化的运行信息。
## 配置 AI 凭证
## Docs
`/ai?tab=providers` 是 AI 模型管理入口。包含两个核心子 tab
文档站入口:
- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试
- `工具`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR
```text
http://localhost:3000/docs
```
### 模型供应商
Docs 正文由后端 API 按权限读取,不再把全部 Markdown 直接打进前端构建产物。当前文档源文件仍位于
provider 和模型既可选预设也可直接输入自定义 id/name。常用字段
```text
docs/technical/zh/*.md
docs/technical/en/*.md
```
- Provider例如 `minimax``openai``anthropic``ollama`
- 协议适配:`OpenAI Chat Completions` / `Anthropic Messages` / `Ollama Generate`
- Base URL模型 API 地址
- 默认模型:例如 `gpt-5.1``MiniMax-M2.7`
- API Key填入后保存保存的 Key 在 UI 中显示为脱敏预览
- Max Tokens、Anthropic Version可保持默认
- Timeout / Retry超时和重试次数
未登录访客默认只能看到 `public` 文档,例如首页、快速开始和使用手册。登录用户如果被分配 Gatekeeper 权限组,可以看到更多技术文档:
Base URL 输入框尾端的插头图标会触发连接测试。测试通过会显示当前模型返回的简短回复。
- `docs_user`:用户操作类文档。
- `docs_developer`Earth、前端、后端、采集器和 AI Provider 等开发文档。
- `docs_admin`:服务控制、运维、环境变量和敏感操作文档。
### 工具
`admin` 默认拥有管理文档权限,`super_admin` 拥有全部 Docs 权限。Gatekeeper 权限组在控制台“用户管理”中配置。
- **WebSearch**provider、API Key、Base URL、最大结果数、超时、高级 provider 参数。未启用时除"启用"开关外其它配置项和连接测试都会置灰
- **OCR**provider、Base URL、API Key、模型/engine、识别语言、超时、最大文件大小、输出格式
Docs 支持:
旧链接 `/settings?tab=ai` 会跳到 `/ai?tab=providers`
- 分类导航
- Markdown 渲染
- 表格和代码块
- 文档内目录
- 对当前可见文档搜索
- technical 文档之间的内部链接跳转
## 系统设置
如果新增 technical 文档,应同步检查
`/settings` 用于管理系统级配置,常用子 tab
- 是否有清晰的一级标题
- 是否需要加入后端 Docs metadata 的人工分类和排序
- 应归入 `public``docs_user``docs_developer` 还是 `docs_admin`
- **系统显示**:系统名称、刷新间隔、数据保留天数、最大并发任务
- **通知策略**:告警邮件开关、收件邮箱、严重/警告/每日摘要通知
- **安全策略**:会话超时、最大登录尝试、密码策略
- **SMTP 邮件**:注册和找回密码所需的发件配置(仅 `admin` / `super_admin` 可见)
- **电视直播**:电视直播源管理
- **AI / WebSearch / OCR**:见上节
## 开发命令约定
### SMTP 邮件设置
前端命令必须使用 Bun
公开注册和验证码功能依赖这一项。`admin``super_admin` 用户在 `/settings` 进入 `SMTP 邮件` 子 tab
```bash
cd frontend
bun install
bun run dev
bun run build
```
- SMTP 主机、端口
- 账号、密码
- 发件地址(必填)、发件人名称
- STARTTLS端口 587 常用) 或 隐式 TLS端口 465
- 超时秒数
不要使用 `npm run ...`。项目在 WSL / Windows 混合环境中优先依赖 Bun避免 Node/npm 路径差异带来的兼容问题
填完保存,再点"发送测试邮件"按钮,输入收件人地址试发一封。测试通过后即可让普通用户走 `/register` 自助注册
验证前端构建:
如果保留密码字段中的脱敏预览不动,保存时不会覆盖原密码;要换密码就输入新值。
```bash
source ~/.zshrc && bun run build
```
## 用户管理(管理员)
## 故障排查顺序
`/users``super_admin` 可创建/删除用户。该页支持:
遇到问题时,建议按这个顺序排查:
- 查看用户列表(用户名、邮箱、角色、是否激活、邮箱是否已验证)
- 创建用户:与公开注册等价,但跳过邮箱验证(管理员认账)
- 修改角色:`viewer` / `operator` / `admin` / `super_admin`
- 调整 Gatekeeper 权限组:`docs_user` / `docs_developer` / `docs_admin`,影响 Docs 站可见文档范围
- 禁用 / 启用账号
1. 看服务状态:
要让普通用户能看开发或运维文档,进 `/users` 给他加 `docs_developer``docs_admin`
```bash
./planet.sh health
```
## 数据探索
2. 看最近日志:
- `/datasources`:数据源目录。可以按产品域、层级、启用状态、最近执行状态、是否已有采集数据和关键词筛选;勾选多行后可批量采集选中项,未勾选时“采集当前筛选”只触发当前筛选范围。点击名称打开信息抽屉查看 endpoint、请求头、基础配置和是否内置接口、凭证、请求头的编辑统一在 `/settings` 的"采集器设置"。总体进度下方的 `采集中 N` 标签可点击,展开当前采集任务列表
- `/data`:采集后数据表,适合排查"数据是否已经进入系统"、"更新时间是否符合预期"、"某个数据源是否产出有效记录"
- `/bgp`BGP 专题页面,列表 + 详情 + 研判,与 Earth 的 BGP 图层互补
- `/alerts/system``/alerts/bgp``/alerts/situational`系统、BGP、态势告警
```bash
./planet.sh log
```
## AI 测试台
3. 按模块查看日志
`/ai?tab=playground` 用于真实分析链路调试。可以
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
- 选择当前 provider
- 用预设请求或自定义 prompt 触发分析
- 观察 AI Provider 状态和返回内容
4. 只重启有问题的模块:
旧链接 `/playground` 会跳到这里。
```bash
./planet.sh restart -f
./planet.sh restart -b
./planet.sh restart -a
```
## Earth 公开页面
5. 如果数据库或缓存异常,再重启数据库:
Earth `http://localhost:3000/earth` 是公开 3D 态势页面不需要登录。React 路由中的 `/earth` 用 iframe 承载独立前端(位于 `frontend/public/earth/`)。
```bash
./planet.sh restart -d
```
### 主要用途
6. 仍无法恢复时,执行全量重启:
在一个地球视图中观察BGP 事件与观测态势、卫星和轨迹、海缆与登陆点、算力中心、国界线/经纬线/高清材质/云图/地形、新闻直播和态势新闻、搜索和聚焦对象详情。
```bash
./planet.sh restart
```
### 图层控制
右侧图层面板用于打开或关闭图层。常见图层经纬线、国界线、高清材质、大气云图、海缆、算力中心、BGP 观测、卫星、AIS 船只、轨迹、地形。
依赖关系:
- 地形依赖高清材质
- 轨迹依赖卫星
- 高清材质关闭时,地球显示基座地图和边缘识别效果
### 图例
左下角图例会跟随当前聚焦或启用的图层切换。已覆盖海缆、卫星、国界线、算力中心、BGP、AIS 船只。
AIS 船只图例按船型显示颜色:货轮、油轮、客船、渔船、军舰、停泊/低速、其他船只。三角形对应航行船只标记,圆点对应停泊或低速状态。
### 搜索
支持查找海缆、登陆点、卫星、算力中心、BGP 事件、BGP 观测站。结果可快速定位并打开详情。
### 位置候选采集
算力中心和 BGP 观测站详情卡支持自动采集坐标候选。点击对象后用"自动采集坐标候选"或"重新自动采集坐标"按钮,后端会从源坐标、开放组织注册 API 和在线地理编码中整理候选;常规来源没有候选时使用当前默认 AI Provider 做 LLM factcheck 兜底。BGP 观测站的已存储位置只用于补齐查询上下文,不会作为候选直接返回。
候选可以直接在 Earth 预览。算力中心候选点击"保存"后写入 `compute_center_locations` 维表并刷新图层。算力中心图层左上角的通知气泡显示无法渲染的待定位数量;点击查看列表,单条采集候选,或用"一键采用"从上到下保存最高置信候选。没有可用候选的记录会留在列表中,不会被国家中心点或硬编码 hint 伪造位置。详细流程见 [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md)。
### 设置
设置面板包含:旋转模式 / 巡航模式 / 动捕模式、巡航模块BGP/新闻/算力中心/船只/海缆/卫星)、视图设置(卫星显示风格、日夜模式、面板显示开关)、动捕调试模式 / 输入源 / 只显示骨骼、地球默认大小、地形透明度、重置设置。
这些设置保存在浏览器本地存储,换浏览器或清理站点数据后会恢复默认值。
### 视角控制
| 操作 | 作用 |
| --- | --- |
| 鼠标左键拖动 | 旋转地球 |
| 手指单指拖动 | 触屏旋转地球 |
| 鼠标滚轮 | 放大或缩小 |
| 双指捏合 | 触屏放大或缩小 |
| 缩放按钮 | 固定步长调整缩放 |
| 点击缩放百分比 | 重置到默认缩放 |
缩放时顶部胶囊会短暂显示当前缩放比例。这个提示不代表数据加载进度;如果页面正在加载数据,加载提示优先显示。
拖动灵敏度会根据当前缩放自动调整:默认视角附近保持常规旋转速度;放大后拖动会逐步变细,缩小后拖动会略快。
### 动作捕捉控制
Earth 预留了动作捕捉控制入口。实时链路两种输入源:
- **浏览器摄像头**(默认):直接用网页 `getUserMedia` 在本机浏览器识别;无需安装应用,但页面必须运行在 HTTPS 或 localhost且需允许浏览器摄像头权限
- **Motion Agent**:摄像头/RTSP/HTTP → 本地 Agent → 本地 WebSocket → Earth 页面用于双摄、USB index、手机/网络摄像头流
打开方式:设置中开启"动捕调试模式",或加 URL 参数 `?motion=1` 打开 Earth 动捕连接。Motion Agent 默认地址 `ws://127.0.0.1:8765/ws/gestures`,可用 `motionAgent` URL 参数覆盖。也可以直接 `?motion=1&motionProvider=browser``?motion=1&motionProvider=agent`
两种模式都不会把摄像头帧或实时手势发到云端,也不会复用新闻/RSS 聚合接口。
手势语义:
| 手势事件 | 作用 |
| --- | --- |
| `rotate_left/right/up/down` | 地球向对应方向旋转 |
| `zoom_in/out` | 放大或缩小视角 |
| `focus_prev/next` | 在当前动捕图层内切换可交互目标 |
| `layer_prev/next` | 切换动捕候选图层并巡航到新图层最近目标 |
| `confirm` | 确认当前已选目标 |
调试面板中浏览器摄像头输入会显示本机实时预览并绘制关节点和连线Motion Agent 只发归一化骨架事件,不发原始帧。"只显示骨骼"会隐藏视频预览只保留骨架;"停止匹配动作"会暂停手势触发但保留预览和骨架。未匹配时骨架红色,匹配后变绿并显示动作名称。
### 巡航模式
巡航模式让 Earth 自动轮播聚焦目标。当前巡航模块BGP、新闻、算力中心、船只、海缆、卫星。适合演示、监控大屏或无人值守。
### 移动端
移动端抽屉布局:图层控制进入移动抽屉,搜索、设置、详情使用移动端面板。主要交互仍围绕地球对象点击、搜索和图层开关。
### 常见问题
- **Earth 打不开**:先确认前端服务是否在线;如果端口不是 `3000`,使用启动输出的实际端口
- **图层没有数据**:进 `/datasources` 看数据源状态、是否已采集和最近执行结果,再到 `/data``/bgp` 看是否有记录
- **卫星 / BGP / 海缆加载慢**:这些图层依赖后端接口和外部数据源,首次加载需要等启动任务完成
## Docs 文档站
文档站 `http://localhost:3000/docs` 由后端按权限读取,不再把全部 Markdown 直接打进前端构建产物。
未登录访客默认只能看到 `public` 文档首页、快速开始、使用手册、常见问题、Earth 位置候选采集使用手册。登录用户被分配 Gatekeeper 权限组后可以看到更多技术文档:
- `docs_user`:用户操作类文档
- `docs_developer`Earth、前端、后端、采集器和 AI Provider 等开发文档
- `docs_admin`:服务控制、运维、环境变量和敏感操作文档(包括运维手册)
`admin` 默认拥有 `docs_admin``super_admin` 拥有全部 Docs 权限。Gatekeeper 权限组在"用户管理"中配置。
Docs 支持分类导航、Markdown 渲染、表格和代码块、文档内目录、对当前可见文档搜索、technical 文档间内部链接跳转。
## 相关文档
- [快速开始](/home/ray/dev/linkong/planet/docs/technical/zh/quickstart.md)
- [控制台前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-admin-frontend-context.md)
- [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
- [Earth 图层样式属性索引](/home/ray/dev/linkong/planet/docs/technical/zh/earth-layer-style-reference.md)
- [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)
- [Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md)
- [系统服务控制](/home/ray/dev/linkong/planet/docs/technical/zh/backend-system-service-control.md)
- [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)
- [数据源、采集器设置与连接验证](/home/ray/dev/linkong/planet/docs/technical/zh/datasource-collector-settings-connectivity.md)
- [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)

View File

@@ -0,0 +1,244 @@
# Planet 运维手册
这份手册面向部署、值班和二次开发的运维人员。客户面向的 UI 使用流程见 [Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md),本手册只覆盖 shell、Docker、日志、环境变量和故障排查。
## 首次启动
```bash
./planet.sh start
```
默认行为:
- 启动 PostgreSQL 和 Redis
- 启动 AI Provider
- 启动后端 API
- 启动前端 Vite dev server
- 输出 Earth、控制台、Playground 和后端 API 文档入口
首次启动会自动写入两个默认账号(见 `backend/app/db/session.py``DEFAULT_LOGIN_USERS`
| 用户名 | 密码 | 角色 |
| --- | --- | --- |
| `admin` | `admin123` | `super_admin` |
| `linkong` | `12345678` | `super_admin` |
两个默认账号 `email_verified``true`,可直接登录控制台。任何在该列表之外的账号都必须走公开注册 + 邮箱验证流程(见使用手册),或用 `./planet.sh createuser` 命令式创建。
可指定端口:
```bash
./planet.sh start -b 8001 -f 3001 -a 8101
```
| 参数 | 含义 |
| --- | --- |
| `-b <port>` | 后端端口 |
| `-f <port>` | 前端端口 |
| `-a <port>` | AI Provider 端口 |
| `--allow-lan` | 允许局域网访问 |
| `--verbose` | 显示更多命令输出 |
## 启停与按模块重启
停止全部:
```bash
./planet.sh stop
```
会停止后端、AI Provider、前端、PostgreSQL、Redis。
按模块重启:
```bash
./planet.sh restart # 全量
./planet.sh restart -b # 后端
./planet.sh restart -f # 前端
./planet.sh restart -a # AI Provider
./planet.sh restart -d # 数据库
```
按模块重启适合日常开发,能避免无关服务被打断。
## 健康检查
```bash
./planet.sh health
```
会检查:
- `planet_*` 容器状态
- 后端 `/health`
- AI Provider `/health`
- 前端页面可达性
如果某项显示 offline优先看对应日志。
## 日志
最近日志:
```bash
./planet.sh log
```
持续跟随:
```bash
./planet.sh log -f # 前端: /tmp/planet_frontend.log
./planet.sh log -b # 后端: /tmp/planet_backend.log
./planet.sh log -a # AI Provider: planet_aiprovider 容器日志
```
## 命令式创建用户
```bash
./planet.sh createuser
```
交互式提示用户名、密码、角色,直接落库并标记 `email_verified = TRUE`
适用场景:
- SMTP 还没配置好,但需要先发账号给一名管理员
- 想批量预置内部测试账号
- 公开注册流程因任何原因不可用,需要应急兜底
正式用户开通推荐走控制台 `/settings -> SMTP 邮件` 配好发件后,让用户在 `/register` 自助注册。
## 局域网 / WSL 访问
```bash
./planet.sh start --allow-lan
```
适用于:
- WSL 中启动Windows 浏览器访问
- 手机或平板演示 Earth
- 局域网其他机器访问同一开发实例
`--allow-lan` 只负责让前端和后端监听 `0.0.0.0`。WSL 中运行时Windows 本机一般可以通过 `localhost` 访问,但局域网其他机器访问 `http://<Windows局域网IP>:3000` 还需要 Windows 端口转发和防火墙放行。
建议按顺序排查:
```bash
# 在运行 Planet 的 shell 中
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
如果看到 `0.0.0.0:3000``0.0.0.0:8000`,但局域网 IP 仍访问失败,在管理员 PowerShell 中配置:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
## AI Provider 环境变量与构建
AI Provider 运行期配置可以放在两处:
| 位置 | 适合内容 | 说明 |
| --- | --- | --- |
| `aiprovider/.env` | 团队约定的本地默认配置 | Docker Compose 作为 `env_file` 读取 |
| `~/.zshrc` | 个人 provider、模型、密钥、代理 | `planet.sh` 启动时读取常见 `AI_*``SERVICE_*``PYTHON_IMAGE``UV_IMAGE` |
推荐写法:
```bash
export AI_PROVIDER=minimax
export AI_PROVIDER_API=anthropic-messages
export AI_BASE_URL=https://api.example.com/anthropic
export AI_API_KEY=sk-change-me
export AI_MODEL=MiniMax-M2.7
export AI_PROVIDER_SERVICE_TOKEN=change_me
```
`planet.sh` 默认只静态解析 `~/.zshrc` 中简单的 `export KEY=value``KEY=value` 行。需要复杂 shell 展开时显式启用:
```bash
PLANET_LOAD_ZSHRC_ENV=source ./planet.sh start -a
```
完全忽略 `~/.zshrc`
```bash
PLANET_LOAD_ZSHRC_ENV=0 ./planet.sh start -a
```
AI Provider 镜像只在代码、Dockerfile、Compose 配置或相关 Python 依赖变化时重建。修改密钥或 Base URL 后只需重启容器:
```bash
./planet.sh restart -a
```
构建较慢时按层排查:
| 现象 | 常见原因 | 处理方式 |
| --- | --- | --- |
| `transferring context` 很大 | build context 含前端资源等无关文件 | `.dockerignore` 只发送必需文件 |
| `uv sync` 下载较慢 | 首次构建或缓存为空 | 等待首次完成,后续复用 BuildKit 缓存 |
| 改密钥后仍是旧配置 | 容器未重启 | `./planet.sh restart -a` |
## SMTP 邮件(公开注册依赖)
公开注册和邮箱验证依赖 SMTP。管理员在控制台 `/settings -> SMTP 邮件` 子 tab 中填写主机、端口、账号、密码、发件地址、TLS 模式,然后用"发送测试邮件"按钮验证。配置同时写入 `system_settings.smtp` 表行。
未配置时 `POST /api/v1/auth/register` 返回 `503 EMAIL_PROVIDER_NOT_CONFIGURED`,前端的注册流程会给出明确提示。运维兜底方式是 `./planet.sh createuser`
OTP 一次性验证码走 Rediskey 格式 `otp:{purpose}:{email}`TTL 600 秒。错误尝试 5 次后该 key 失效;重发冷却 60 秒,由 `otp_rate:{purpose}:{email}` 控制。
## 故障排查顺序
```bash
./planet.sh health # 1. 看服务状态
./planet.sh log # 2. 看最近日志
./planet.sh log -f # 3. 按模块查看
./planet.sh log -b
./planet.sh log -a
./planet.sh restart -f # 4. 只重启有问题的模块
./planet.sh restart -b
./planet.sh restart -a
./planet.sh restart -d # 5. 数据库 / 缓存异常时
./planet.sh restart # 6. 仍无法恢复时全量重启
```
## 开发命令约定
前端必须使用 Bun
```bash
cd frontend
bun install
bun run dev
bun run build
```
不要使用 `npm run ...`。项目在 WSL / Windows 混合环境优先依赖 Bun避免 Node/npm 路径差异。
验证前端构建:
```bash
source ~/.zshrc && bun run build
```
后端依赖通过 uv 管理:
```bash
uv sync
uv run pytest backend/tests/test_otp_service.py
```
## 相关文档
- [planet.sh 启动机制](/home/ray/dev/linkong/planet/docs/technical/zh/ops-planet-sh-startup.md)
- [系统服务控制](/home/ray/dev/linkong/planet/docs/technical/zh/backend-system-service-control.md)
- [Docker + Compose + Buildx 升级](/home/ray/dev/linkong/planet/docs/technical/zh/ops-docker-compose-buildx-upgrade.md)
- [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)

View File

@@ -1,236 +1,66 @@
# 快速开始
这份快速开始面向第一次启动 Planet 的开发者或演示操作者。目标是用最短路径把服务跑起来,并知道应该打开哪些入口
这份快速开始面向 Planet 的最终用户:你拿到了管理员给的访问地址,要从打开浏览器到第一次完成配置之间的最短路径。所有操作都在浏览器里完成
如果遇到端口占用、Windows / WSL 局域网访问、`uv` / `bun`、摄像头或 Docs 权限问题,先看 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
如果你是负责部署或运维的同事,请改读 [Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)。
## 前置条件
## 1. 打开访问地址
推荐在 WSL / Linux shell 中运行
打开管理员给的 URL例如 `http://planet.example.com`。本地演示一般是 `http://localhost:3000`
需要具备
入口分两类
- Docker / Docker Compose 可用
- 当前 shell 能访问 `uv``bun`
- 仓库已 clone 到本机
- 公开页:`/earth`3D 态势)、`/docs`(公共文档)
- 登录后:`/admin`(控制台)、`/ai`AI`/settings`(系统配置)
如果是新机器,优先执行仓库自带初始化脚本:
## 2. 注册账号
```bash
./scripts/bootstrap-dev.sh
```
1. 打开 `/login`,点击表单下方"注册账户"。
2.`/register` 填用户名、邮箱、密码(至少 8 位)。
3. 提交后,到邮箱查收 6 位验证码10 分钟内有效)。
4. 在验证页输入验证码,点击"验证并登录",系统会自动跳到控制台。
这个脚本会检查并同步常用依赖,并在缺少时生成
收不到邮件时
- `backend/.env`
- `aiprovider/.env`
- `frontend/.env.local`
- 看一下垃圾邮件或企业邮件网关
- 验证页右下角的"重新发送验证码"会显示倒计时,结束后可以重发
- 提示"邮件服务尚未配置"时联系管理员开 SMTP
AI Provider 的个人配置也可以放在 `~/.zshrc``planet.sh` 会读取简单的 `export AI_...=...``AI_...=...` 行,并在启动 AI Provider 时传给容器。修改模型、密钥或 Base URL 后,通常只需要重启 AI Provider
默认角色是 `viewer`,可登录但只能看公共内容。要看采集器、用户管理、系统设置,请联系管理员升级角色或加 Gatekeeper 权限组。
```bash
./planet.sh restart -a
```
## 3. 第一次登录
AISStream、BarentsWatch 等采集器凭证也可以先写在 `~/.zshrc` 里供连接验证读取,例如
进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台
```bash
export AISSTREAM_API_KEY="..."
export BARENTSWATCH_CLIENT_ID="..."
export BARENTSWATCH_CLIENT_SECRET="..."
```
1. `/settings?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector开源 BGP 等)通常直接可用;像 `AISStream``BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
2. `/ai?tab=providers`:填一个 LLM provider例如 `minimax` / `openai`、模型名、Base URL、API Key点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
3. `/datasources``/data`:看采集器是否已经产出数据
4. `/alerts/system`:看系统告警是否正常
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
正式采集更推荐在控制台 `设置 -> 采集器设置` 保存凭证,尤其是 AISStream 这类长连接 WebSocket collector。这样连接验证、后端采集任务和 Earth 实时船只聚合会使用同一份配置。
## 4. 打开 Earth
## 1. 启动服务
访问 `/earth`,公开页面,不需要登录。
在仓库根目录执行
进入后建议确认
```bash
./planet.sh start
```
启动完成后,常用入口是:
| 入口 | 默认地址 | 用途 |
| --- | --- | --- |
| Earth | `http://localhost:3000/earth` | 公开 3D Earth 可视化页面 |
| 控制台 | `http://localhost:3000/admin` | 登录后的管理后台 |
| 文档站 | `http://localhost:3000/docs` | 使用手册公开;开发/运维文档按 Gatekeeper 权限组开放 |
| AI | `http://localhost:3000/ai` | 登录后的模型供应商、工具和测试台入口 |
| 后端 API 文档 | `http://localhost:8000/docs` | FastAPI / OpenAPI 接口文档 |
如果默认端口被占用,可以指定端口:
```bash
./planet.sh start -f 3001 -b 8001 -a 8101
```
后端 `8000` 被 Windows listener 或旧 portproxy 占用时,排查顺序见 [常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)。
## 2. 创建登录用户
控制台需要登录。首次使用可以执行:
```bash
./planet.sh createuser
```
按提示输入用户名、密码和角色。
如果需要阅读开发或运维文档,用 `super_admin` 登录控制台后,在“用户管理”里给目标用户分配 Gatekeeper 权限组:`docs_developer` 用于开发文档,`docs_admin` 用于服务控制和运维文档。
## 3. 打开 Earth
访问:
```text
http://localhost:3000/earth
```
Earth 是公开页面,不需要登录。
进入后可以先确认:
- 地球正常显示
- 右侧图层控制可打开/关闭图层
- 地球正常显示,右侧图层面板可以打开/关闭
- 搜索可以查找海缆、卫星、算力中心、BGP 事件
- 算力中心和 BGP 观测站详情卡可以自动采集并预览坐标候选;常规来源无候选时会用当前默认 AI Provider 做 LLM factcheck 兜底;算力中心待定位气泡可以打开列表并保存候选
- 鼠标拖动、滚轮缩放缩放百分比提示正常工作
- 设置面板可以切换旋转 / 巡航 / 动捕模式、日夜模式、卫星显示风格;动捕调试模式下浏览器摄像头可显示本机预览和骨架
- 算力中心和 BGP 观测站详情卡可以自动采集坐标候选,并能在 Earth 上预览
- 鼠标拖动、滚轮缩放缩放百分比提示工作正常
- 设置面板旋转 / 巡航 / 动捕模式可以切换
## 4. 打开控制台
## 5. 找回密码
访问:
忘记密码时打开 `/forgot-password`,输入邮箱并接收验证码,再输入验证码 + 新密码即可。系统对未注册的邮箱也会返回同样的提示(防止账号枚举)。
```text
http://localhost:3000/admin
```
## 6. 看文档
控制台用于数据源、采集数据、专题观测、告警、系统日志和配置管理
首次排查建议查看:
- `/datasources`:数据源目录和采集触发;接口、请求头和凭证配置在 `/settings` 的“采集器设置”
- `/data`:已采集数据
- `/bgp`BGP 专题观测
- `/ai`AI管理模型供应商、WebSearch 等工具和测试台
- `/alerts/system`:系统告警
- `/settings`:系统配置
## 5. 查看运行状态
```bash
./planet.sh health
```
这个命令会显示容器状态,并检查:
- 后端
- AI Provider
- 前端
## 6. 查看日志
最近日志:
```bash
./planet.sh log
```
持续查看某个服务:
```bash
./planet.sh log -f
./planet.sh log -b
./planet.sh log -a
```
含义:
- `-f`:前端日志
- `-b`:后端日志
- `-a`AI Provider 日志
## 7. 常用重启
只重启前端:
```bash
./planet.sh restart -f
```
只重启后端:
```bash
./planet.sh restart -b
```
只重启 AI Provider
```bash
./planet.sh restart -a
```
只重启数据库:
```bash
./planet.sh restart -d
```
全量重启:
```bash
./planet.sh restart
```
## 8. 局域网访问
如果希望 Windows 浏览器、手机或同一局域网的其他设备访问:
```bash
./planet.sh start --allow-lan
```
这会让前端和后端监听局域网可访问地址。
注意:`--allow-lan` 只负责让 Planet 服务监听 `0.0.0.0`,不等于自动把 WSL 服务暴露到 Windows 局域网 IP。常见情况是
- WSL 内 `localhost:3000` / `localhost:8000` 能访问
- Windows 本机 `localhost:3000` / `localhost:8000` 能访问
- 但手机或其他电脑访问 `http://<Windows局域网IP>:3000` 失败
这通常说明 Windows 端还缺少端口转发或防火墙放行。
如果访问失败,先在运行 Planet 的 shell 中检查:
```bash
curl http://localhost:3000
curl http://localhost:8000/health
ss -ltnp | grep -E ':3000|:8000'
```
如果确认 WSL 中已监听 `0.0.0.0:3000``0.0.0.0:8000`,但局域网 IP 仍不能访问,请在管理员 PowerShell 中配置 Windows 端转发和防火墙:
```powershell
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=3000 connectaddress=127.0.0.1 connectport=3000
netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8000 connectaddress=127.0.0.1 connectport=8000
New-NetFirewallRule -DisplayName "WSL Planet 3000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 3000
New-NetFirewallRule -DisplayName "WSL Planet 8000" -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8000
```
## 9. 停止服务
```bash
./planet.sh stop
```
停止后会关闭前端、后端、AI Provider、PostgreSQL 和 Redis。
`/docs` 是文档站。未登录可看:本快速开始、使用手册、常见问题。登录后被分配 `docs_user` / `docs_developer` / `docs_admin` 权限组的用户可以看更多技术文档
## 下一步
- 完整操作说明[Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md)
- 控制台结构见 [控制台前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/frontend-admin-frontend-context.md)
- Earth 结构见 [Earth 前端结构](/home/ray/dev/linkong/planet/docs/technical/zh/earth-frontend-context.md)
- 后端采集器见 [数据采集系统](/home/ray/dev/linkong/planet/docs/technical/zh/backend-collectors.md)
- 完整 UI 操作说明[Planet 使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/manual.md)
- 排障与配置疑问:[常见问题](/home/ray/dev/linkong/planet/docs/technical/zh/faq.md)
- Earth 坐标候选采集详细流程:[Earth 位置候选采集使用手册](/home/ray/dev/linkong/planet/docs/technical/zh/location-pipeline-user.md)
- 部署 / 运维相关命令:[Planet 运维手册](/home/ray/dev/linkong/planet/docs/technical/zh/ops-runbook.md)

View File

@@ -16,12 +16,13 @@
## Current Version
- `main` 当前主线历史推导到:`0.16.5`
- `dev` 当前开发分支历史推导到:`0.51.1`
- `dev` 当前开发分支历史推导到:`0.52.0`
## Timeline
| Version | Type | Branch | Commit | Summary |
| --- | --- | --- | --- | --- |
| `0.52.0` | feature | `dev` | `pending` | 新增邮箱验证码账号自助链路、AISStream 船只实时采集与受控 snapshot/WS 展示、数据产品统计接口、受控图层接口和数据源批量运维 |
| `0.51.1` | bugfix | `dev` | `pending` | 修复 Earth 静态资源模块相对路径与 Material Symbols 本地字体加载,避免部署路径变化或外部字体不可用时图标/边界资源失效 |
| `0.51.0` | feature | `dev` | `pending` | 新增 AI Settings 控制台与 ai_tools 工具层;重写算力中心候选预览/保存交互(呼吸圈 + 即时图标);动作捕捉 zoom 改为 mirror-safe trend + pose hold 双通道,支持持续触发 |
| `0.50.0` | feature | `dev` | `pending` | 新增 Earth 动捕双通道控制、Motion Agent、Presentation 持久展示、AI Provider 多 provider 设置、位置候选 LLM 兜底与 FAQ |

View File

@@ -1,6 +1,6 @@
{
"name": "planet-frontend",
"version": "0.51.1",
"version": "0.52.0",
"private": true,
"packageManager": "bun@1",
"dependencies": {

View File

@@ -206,7 +206,7 @@ export const PATHS = {
cablesApi: '/api/v1/visualization/geo/cables',
landingPointsApi: '/api/v1/visualization/geo/landing-points',
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
vesselsApi: '/api/v1/visualization/geo/vessels',
vesselsApi: '/api/v1/vessels/snapshot',
vesselTrackApi: (mmsi) => `/api/v1/visualization/vessels/${encodeURIComponent(mmsi)}/track`,
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',

View File

@@ -258,6 +258,11 @@ const vesselIconLayer = createInteractableLayer({
}),
});
const DEFAULT_VESSEL_VIEWPORT = {
bbox: [-180, -90, 180, 90],
zoom: 2,
};
export function getVesselMarkers() {
return vesselIconLayer.getMarkers();
}
@@ -313,6 +318,14 @@ export function clearVesselData(earth) {
export async function loadVessels(_scene, earth, options = {}) {
const params = new URLSearchParams();
const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers);
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
? options.bbox
: DEFAULT_VESSEL_VIEWPORT.bbox;
const zoom = Number.isFinite(Number(options.zoom))
? Number(options.zoom)
: DEFAULT_VESSEL_VIEWPORT.zoom;
params.set("bbox", bbox.join(","));
params.set("zoom", String(zoom));
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
params.set("limit", String(requestedLimit));
}
@@ -352,7 +365,15 @@ export function startVesselRealtime(earth, { onUpdate } = {}) {
connected: true,
};
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
socket.send(JSON.stringify({ type: "subscribe", data: { channels: ["vessels"] } }));
socket.send(JSON.stringify({
type: "subscribe",
data: {
channel: "vessels",
bbox: DEFAULT_VESSEL_VIEWPORT.bbox,
zoom: DEFAULT_VESSEL_VIEWPORT.zoom,
limit: VESSEL_CONFIG.maxRenderedMarkers,
},
}));
};
socket.onmessage = (event) => {
let message;

View File

@@ -1,11 +1,14 @@
import { Suspense, lazy } from 'react'
import { Spin } from 'antd'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './stores/auth'
import Login from './pages/Login/Login'
const Register = lazy(() => import('./pages/Register/Register'))
const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail'))
const ForgotPassword = lazy(() => import('./pages/ForgotPassword/ForgotPassword'))
const SystemAlerts = lazy(() => import('./pages/Alerts/SystemAlerts'))
const BGPAlerts = lazy(() => import('./pages/Alerts/BGPAlerts'))
const SituationalAlerts = lazy(() => import('./pages/Alerts/SituationalAlerts'))
@@ -25,7 +28,8 @@ const EARTH_ROUTE = '/earth'
const DOCS_ROUTE = '/docs'
const DOCS_ROUTE_PATTERN = '/docs/:slug'
const DOCS_ROUTE_PREFIX = `${DOCS_ROUTE}/`
const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE])
const AUTH_ROUTES = new Set(['/login', '/register', '/verify-email', '/forgot-password'])
const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE, ...AUTH_ROUTES])
function isPublicPath(pathname: string) {
return PUBLIC_EXACT_ROUTES.has(pathname) || pathname.startsWith(DOCS_ROUTE_PREFIX)
@@ -33,7 +37,8 @@ function isPublicPath(pathname: string) {
function App() {
const { token } = useAuthStore()
const isPublicRoute = isPublicPath(window.location.pathname)
const { pathname } = useLocation()
const isPublicRoute = isPublicPath(pathname)
if (!token && !isPublicRoute) {
return <Login />
@@ -48,6 +53,10 @@ function App() {
)}
>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/admin" element={<Dashboard />} />
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
<Route path={EARTH_ROUTE} element={<Earth />} />
@@ -71,4 +80,4 @@ function App() {
)
}
export default App
export default App

View File

@@ -0,0 +1,107 @@
import type { ReactNode } from 'react'
import { Button, Input, Tooltip, type InputProps } from 'antd'
export function PlugConnectIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 12l5 5l-1.5 1.5a3.536 3.536 0 1 1 -5 -5l1.5 -1.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M17 12l-5 -5l1.5 -1.5a3.536 3.536 0 1 1 5 5l-1.5 1.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 21l2.5 -2.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18.5 5.5l2.5 -2.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 11l-2 2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 14l-2 2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
interface ConnectionTestInputProps extends Omit<InputProps, 'suffix'> {
testTitle?: ReactNode
testing?: boolean
testDisabled?: boolean
onTest?: () => void
extraSuffix?: ReactNode
}
export default function ConnectionTestInput({
testTitle = '测试连接',
testing = false,
testDisabled = false,
disabled,
onTest,
extraSuffix,
...inputProps
}: ConnectionTestInputProps) {
const actionDisabled = disabled || testDisabled
return (
<Input
{...inputProps}
disabled={disabled}
suffix={(
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
<Tooltip title={testTitle}>
<Button
type="text"
size="small"
icon={<PlugConnectIcon />}
loading={testing}
disabled={actionDisabled}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
if (!actionDisabled) {
onTest?.()
}
}}
aria-label="测试连接"
/>
</Tooltip>
{extraSuffix}
</span>
)}
/>
)
}

View File

@@ -363,18 +363,10 @@ body {
height: 100%;
min-height: 0;
display: flex;
gap: 12px;
}
.playground-shell__sidebar {
flex: 0 0 340px;
min-width: 300px;
max-width: 360px;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 12px;
display: none;
}
.playground-card {
@@ -400,7 +392,7 @@ body {
}
.playground-chat__service-btn {
display: none;
display: inline-flex;
}
.playground-card__icon-button:hover {
@@ -1349,14 +1341,6 @@ body {
flex-direction: column;
}
.playground-chat__service-btn {
display: inline-flex;
}
.playground-shell__sidebar {
display: none;
}
.playground-result__meta,
.playground-result__blocks-head {
align-items: stretch;
@@ -1585,6 +1569,26 @@ body {
line-height: 1;
}
.data-source-filter-toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
}
.data-source-filter-toolbar__control {
min-width: 132px;
}
.data-source-filter-toolbar__search {
flex: 1 1 220px;
min-width: 220px;
}
.data-source-table-region {
flex: 1 1 auto;
min-height: 0;
@@ -1628,6 +1632,20 @@ body {
flex-direction: column;
}
.data-source-table-region .ant-table-selection-column {
width: 40px;
min-width: 40px;
max-width: 40px;
padding-inline: 0 !important;
text-align: center;
overflow: visible;
text-overflow: clip;
}
.data-source-table-region .ant-table-selection-column .ant-checkbox-wrapper {
display: inline-flex;
}
.data-source-empty-state {
flex: 1 1 auto;
min-height: 0;

View File

@@ -3,12 +3,12 @@ import {
ApiOutlined,
EyeInvisibleOutlined,
EyeOutlined,
PlayCircleOutlined,
SyncOutlined,
ToolOutlined,
} from '@ant-design/icons'
import {
Alert,
AutoComplete,
Button,
Card,
Checkbox,
@@ -21,17 +21,23 @@ import {
Switch,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd'
import axios from 'axios'
import { useSearchParams } from 'react-router-dom'
import AppLayout from '../../components/AppLayout/AppLayout'
import ConnectionTestInput from '../../components/ConnectionTestInput/ConnectionTestInput'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { PlaygroundWorkspace } from '../Playground/Playground'
const { Title, Text } = Typography
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
const DEFAULT_PROVIDER_MAX_TOKENS = 4096
const TOOL_OPTIONS = [
{ value: 'web_search', label: 'WebSearch 证据层' },
{ value: 'ocr', label: 'OCR 识别' },
]
interface SecretStatus {
configured: boolean
@@ -115,6 +121,18 @@ interface ExternalIntegrations {
scrape_formats: string[]
source: string
}
ocr: {
enabled: boolean
provider: string
base_url: string
api_key: SecretStatus
model: string
languages: string[]
timeout_seconds: number
max_file_size_mb: number
output_format: string
source: string
}
}
interface AIProviderPreset {
@@ -170,15 +188,20 @@ export default function AISettings() {
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
const [testingWebSearchConnection, setTestingWebSearchConnection] = useState(false)
const [selectedToolKey, setSelectedToolKey] = useState('web_search')
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
const [revealedWebSearchSecrets, setRevealedWebSearchSecrets] = useState<Record<string, { api_key: string }>>({})
const [revealedOcrSecrets, setRevealedOcrSecrets] = useState<{ api_key: string } | null>(null)
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
const [webSearchApiKeyRevealed, setWebSearchApiKeyRevealed] = useState(false)
const [ocrApiKeyRevealed, setOcrApiKeyRevealed] = useState(false)
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], form)
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], form)
const webSearchEnabled = Form.useWatch(['web_search', 'enabled'], form)
const ocrEnabled = Form.useWatch(['ocr', 'enabled'], form)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
: integrations?.ai_provider.api_key
@@ -255,6 +278,19 @@ export default function AISettings() {
scrape_path: integrations.web_search.scrape_path,
scrape_formats: integrations.web_search.scrape_formats,
},
ocr: {
enabled: integrations.ocr.enabled,
provider: integrations.ocr.provider,
base_url: integrations.ocr.base_url,
api_key: integrations.ocr.api_key.configured
? integrations.ocr.api_key.preview
: '',
model: integrations.ocr.model,
languages: integrations.ocr.languages,
timeout_seconds: integrations.ocr.timeout_seconds,
max_file_size_mb: integrations.ocr.max_file_size_mb,
output_format: integrations.ocr.output_format,
},
})
}, [form, integrations, loading])
@@ -313,9 +349,23 @@ export default function AISettings() {
}
}
const buildOcrDraftPayload = (values: any) => {
const nextApiKey = String(values.ocr?.api_key || '').trim()
const apiKeyUnchanged = isSecretDraftUnchanged(
nextApiKey,
integrations?.ocr.api_key.preview,
revealedOcrSecrets?.api_key,
)
return {
...values.ocr,
api_key: apiKeyUnchanged ? '' : nextApiKey,
}
}
const buildIntegrationsPayload = (values: any) => ({
ai_provider: buildAiProviderDraftPayload(values),
web_search: buildWebSearchDraftPayload(values),
ocr: buildOcrDraftPayload(values),
barentswatch: {
endpoint: integrations?.barentswatch.endpoint || '',
client_id: integrations?.barentswatch.client_id || '',
@@ -366,6 +416,16 @@ export default function AISettings() {
return secrets
}
const revealOcrSecrets = async () => {
if (revealedOcrSecrets) return revealedOcrSecrets
const response = await axios.get('/api/v1/settings/integrations/ocr/secrets')
const secrets = {
api_key: String(response.data.api_key || ''),
}
setRevealedOcrSecrets(secrets)
return secrets
}
const handleAiProviderApiKeyVisibleChange = async (visible: boolean) => {
const provider = String(form.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
const providerSecret = integrations?.ai_provider.providers?.[provider]?.api_key || integrations?.ai_provider.api_key
@@ -428,6 +488,25 @@ export default function AISettings() {
setWebSearchApiKeyRevealed(false)
}
const handleOcrApiKeyVisibleChange = async (visible: boolean) => {
if (visible) {
try {
const secrets = await revealOcrSecrets()
if (secrets.api_key) form.setFieldValue(['ocr', 'api_key'], secrets.api_key)
setOcrApiKeyRevealed(true)
} catch {
message.error('读取 OCR API Key 失败')
}
return
}
const currentValue = String(form.getFieldValue(['ocr', 'api_key']) || '')
const revealedValue = revealedOcrSecrets?.api_key
if (revealedValue && currentValue === revealedValue) {
form.setFieldValue(['ocr', 'api_key'], integrations?.ocr.api_key.preview || '')
}
setOcrApiKeyRevealed(false)
}
const applyAiProviderSelection = (provider: string, presetOverride?: AIProviderPreset) => {
const preset = presetOverride || aiProviderPresets.find((item) => item.provider === provider)
const savedProvider = integrations?.ai_provider.providers?.[provider]
@@ -567,31 +646,40 @@ export default function AISettings() {
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
<Select
showSearch
optionFilterProp="label"
<AutoComplete
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
options={aiProviderPresets.map((preset) => ({
value: preset.provider,
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => applyAiProviderSelection(value)}
onSelect={(value) => applyAiProviderSelection(value)}
placeholder="选择或输入 provider id例如 openai"
/>
</Form.Item>
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
<ConnectionTestInput
placeholder="https://api.example.com/v1"
testing={testingAiProviderConnection}
testDisabled={!selectedAiProvider}
onTest={() => { void testAiProviderConnection() }}
extraSuffix={(
<Tooltip title="刷新当前 Provider 的模型配置">
<Button
type="text"
size="small"
icon={<SyncOutlined spin={refreshingAiPreset} />}
disabled={!selectedAiProvider}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void refreshSelectedAiProviderPreset()
}}
aria-label="刷新当前 Provider 的模型配置"
/>
</Tooltip>
)}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button icon={<SyncOutlined />} loading={refreshingAiPreset} onClick={refreshSelectedAiProviderPreset}>
</Button>
<Button icon={<PlayCircleOutlined />} loading={testingAiProviderConnection} onClick={() => { void testAiProviderConnection() }}>
</Button>
</Space>
</Form.Item>
</div>
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
<Select>
<Select.Option value="openai-completions">OpenAI Chat Completions</Select.Option>
@@ -600,12 +688,12 @@ export default function AISettings() {
</Select>
</Form.Item>
<Form.Item name={['ai_provider', 'model']} label="默认模型">
<Select
showSearch
optionFilterProp="label"
<AutoComplete
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
options={(
aiProviderPresets.find((preset) => preset.provider === selectedAiProvider)?.models || []
).map((model) => ({ value: model, label: model }))}
placeholder="选择或输入模型名,例如 gpt-5.1"
/>
</Form.Item>
<Form.Item label="LLM API Key">
@@ -621,13 +709,15 @@ export default function AISettings() {
autoComplete="new-password"
placeholder="输入新的 LLM API key"
suffix={(
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
<Tooltip title={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}>
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -664,13 +754,15 @@ export default function AISettings() {
autoComplete="new-password"
placeholder="输入新的代理 token"
suffix={(
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
<Tooltip title={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}>
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -693,97 +785,196 @@ export default function AISettings() {
children: (
<AISettingsPanel loading={loading}>
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
<Card size="small" title={<Space><ToolOutlined />WebSearch </Space>}>
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
<Card size="small" title={<Space><ToolOutlined />AI Tools</Space>}>
<Form.Item label="工具">
<Select
showSearch
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
label: preset.label,
}))}
onChange={(value) => applyWebSearchProviderSelection(value)}
value={selectedToolKey}
options={TOOL_OPTIONS}
onChange={setSelectedToolKey}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<Input placeholder="https://api.tavily.com" />
</Form.Item>
<Form.Item label=" ">
<Button icon={<PlayCircleOutlined />} loading={testingWebSearchConnection} onClick={() => { void testWebSearchConnection() }}>
</Button>
</Form.Item>
</div>
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> WebSearch Provider key</Text>
</Space>
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Button
type="text"
size="small"
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
)}
{selectedToolKey === 'web_search' ? (
<>
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
<Select
showSearch
disabled={!webSearchEnabled}
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
label: preset.label,
}))}
onChange={(value) => applyWebSearchProviderSelection(value)}
/>
</Form.Item>
</Space>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={120} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
<Input placeholder="/search" />
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<ConnectionTestInput
placeholder="https://api.tavily.com"
disabled={!webSearchEnabled}
testing={testingWebSearchConnection}
testDisabled={!webSearchEnabled || !selectedWebSearchProvider}
onTest={() => { void testWebSearchConnection() }}
/>
</Form.Item>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input placeholder="basic" />
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> WebSearch Provider key</Text>
</Space>
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
disabled={!webSearchEnabled}
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Tooltip title={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}>
<Button
type="text"
size="small"
disabled={!webSearchEnabled}
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
</Space>
</Form.Item>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input placeholder="google" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
<InputNumber min={1} max={20} disabled={!webSearchEnabled} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={120} disabled={!webSearchEnabled} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
<Input disabled={!webSearchEnabled} placeholder="/search" />
</Form.Item>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input disabled={!webSearchEnabled} placeholder="basic" />
</Form.Item>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input disabled={!webSearchEnabled} placeholder="google" />
</Form.Item>
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
<Input disabled={!webSearchEnabled} placeholder="general" />
</Form.Item>
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input disabled={!webSearchEnabled} placeholder="/v2/search" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input disabled={!webSearchEnabled} placeholder="/v2/scrape" />
</Form.Item>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox disabled={!webSearchEnabled}> Answer</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox disabled={!webSearchEnabled}> Raw Content</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox disabled={!webSearchEnabled}> Result Text</Checkbox>
</Form.Item>
</Space>
</Card>
</>
) : null}
{selectedToolKey === 'ocr' ? (
<>
<Form.Item name={['ocr', 'enabled']} label="启用 OCR" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
<Input placeholder="general" />
<Form.Item name={['ocr', 'provider']} label="OCR Provider">
<Select
disabled={!ocrEnabled}
options={[
{ value: 'paddleocr', label: 'PaddleOCR / Local' },
{ value: 'tesseract', label: 'Tesseract / Local' },
{ value: 'azure', label: 'Azure AI Vision' },
{ value: 'google_vision', label: 'Google Cloud Vision' },
{ value: 'custom', label: 'Custom HTTP OCR' },
]}
/>
</Form.Item>
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input placeholder="/v2/search" />
<Form.Item name={['ocr', 'base_url']} label="OCR Base URL">
<Input disabled={!ocrEnabled} placeholder="http://localhost:8020 或云 OCR endpoint" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input placeholder="/v2/scrape" />
<Form.Item label="OCR API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ocr.api_key.configured ? 'green' : 'default'}>
{integrations?.ocr.api_key.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> OCR OCR HTTP </Text>
</Space>
<Form.Item name={['ocr', 'api_key']} noStyle>
<Input
autoComplete="new-password"
disabled={!ocrEnabled}
placeholder="输入新的 OCR API key"
suffix={(
<Tooltip title={ocrApiKeyRevealed ? '隐藏 OCR API key' : '显示 OCR API key'}>
<Button
type="text"
size="small"
disabled={!ocrEnabled}
icon={ocrApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleOcrApiKeyVisibleChange(!ocrApiKeyRevealed) }}
aria-label={ocrApiKeyRevealed ? '隐藏 OCR API key' : '显示 OCR API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
</Space>
</Form.Item>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Answer</Checkbox>
<Form.Item name={['ocr', 'model']} label="模型 / Engine">
<Input disabled={!ocrEnabled} placeholder="例如PP-OCRv5、tesseract-default、prebuilt-read" />
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Raw Content</Checkbox>
<Form.Item name={['ocr', 'languages']} label="识别语言">
<Select
mode="tags"
disabled={!ocrEnabled}
options={[
{ value: 'zh', label: '中文' },
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
]}
/>
</Form.Item>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Result Text</Checkbox>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['ocr', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={300} disabled={!ocrEnabled} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['ocr', 'max_file_size_mb']} label="最大文件(MB)">
<InputNumber min={1} max={200} disabled={!ocrEnabled} style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name={['ocr', 'output_format']} label="输出格式">
<Select
disabled={!ocrEnabled}
options={[
{ value: 'markdown', label: 'Markdown' },
{ value: 'text', label: 'Plain Text' },
{ value: 'json', label: 'JSON Blocks' },
]}
/>
</Form.Item>
</Space>
</Card>
</>
) : null}
</Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
Tool

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Key } from 'react'
import {
Button,
Card,
@@ -11,6 +12,7 @@ import {
Modal,
Progress,
Row,
Select,
Space,
Table,
Tag,
@@ -50,6 +52,7 @@ interface BuiltInDataSource {
last_run: string | null
last_run_at?: string | null
last_status?: string | null
product?: string
is_running: boolean
task_id: number | null
progress: number | null
@@ -61,6 +64,8 @@ interface BuiltInDataSource {
phase_unit?: string | null
records_processed: number | null
total_records: number | null
collected_records?: number
has_collected_data?: boolean
is_free?: boolean
requires_credentials?: boolean
credential_provider?: string | null
@@ -108,6 +113,7 @@ interface UnifiedDataSource {
auth_type?: string
last_run_at?: string | null
last_status?: string | null
product?: string
is_running?: boolean
progress?: number | null
phase?: string | null
@@ -126,6 +132,8 @@ interface UnifiedDataSource {
requires_credentials?: boolean
credential_provider?: string | null
credential_status?: string
collected_records?: number
has_collected_data?: boolean
}
interface ViewDataSource extends UnifiedDataSource {
@@ -166,6 +174,32 @@ type DatasourceTaskStatus = {
status?: string | null
}
type ActiveFilter = 'enabled' | 'disabled' | 'all'
type StatusFilter = 'all' | 'success' | 'failed' | 'running' | 'not_run'
type CollectedFilter = 'all' | 'collected' | 'uncollected'
const PRODUCT_LABELS: Record<string, string> = {
vessels: '船只',
cables: '海底光缆',
satellites: '卫星',
bgp: 'BGP',
compute: '算力',
ai: 'AI',
media: '媒体',
other: '其他',
}
const PRODUCT_TAG_COLORS: Record<string, string> = {
vessels: 'cyan',
cables: 'geekblue',
satellites: 'purple',
bgp: 'volcano',
compute: 'blue',
ai: 'magenta',
media: 'green',
other: 'default',
}
function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
return {
key: `builtin:${source.id}`,
@@ -175,6 +209,7 @@ function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
source: source.source,
module: source.module,
priority: source.priority,
product: source.product,
frequency: source.frequency,
endpoint: source.endpoint,
is_active: source.is_active,
@@ -194,6 +229,8 @@ function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
requires_credentials: source.requires_credentials,
credential_provider: source.credential_provider,
credential_status: source.credential_status,
collected_records: source.collected_records,
has_collected_data: source.has_collected_data,
headers: {},
config: {},
}
@@ -208,6 +245,13 @@ function DataSources() {
const [loading, setLoading] = useState(false)
const [triggerAllLoading, setTriggerAllLoading] = useState(false)
const [forceTriggerAll, setForceTriggerAll] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
const [productFilter, setProductFilter] = useState<string>('all')
const [moduleFilter, setModuleFilter] = useState<string>('all')
const [activeFilter, setActiveFilter] = useState<ActiveFilter>('enabled')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [collectedFilter, setCollectedFilter] = useState<CollectedFilter>('all')
const [searchQuery, setSearchQuery] = useState('')
const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
const [runningTasksVisible, setRunningTasksVisible] = useState(false)
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
@@ -216,8 +260,15 @@ function DataSources() {
const tableRegionRef = useRef<HTMLDivElement | null>(null)
const allSources = useMemo(() => builtInSources.map(normalizeBuiltin), [builtInSources])
const selectedSourceIds = useMemo(
() => selectedRowKeys
.map((key) => allSources.find((source) => source.key === key)?.id)
.filter((id): id is number => typeof id === 'number'),
[allSources, selectedRowKeys],
)
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
const collectedBuiltInCount = builtInSources.filter((source) => source.has_collected_data).length
const runningBuiltInSources = builtInSources.filter((source) => source.is_running)
const runningBuiltInCount = runningBuiltInSources.length
const aggregateProgress = runningBuiltInCount > 0
@@ -231,8 +282,16 @@ function DataSources() {
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params = {
product: productFilter === 'all' ? undefined : productFilter,
module: moduleFilter === 'all' ? undefined : moduleFilter,
is_active: activeFilter === 'all' ? undefined : activeFilter === 'enabled',
run_status: statusFilter === 'all' ? undefined : statusFilter,
collected: collectedFilter === 'all' ? undefined : collectedFilter === 'collected',
q: searchQuery.trim() || undefined,
}
const [builtinRes, customRes] = await Promise.all([
axios.get('/api/v1/datasources'),
axios.get('/api/v1/datasources', { params }),
axios.get('/api/v1/datasources/configs'),
])
setBuiltInSources(builtinRes.data.data || [])
@@ -243,12 +302,17 @@ function DataSources() {
} finally {
setLoading(false)
}
}, [messageApi])
}, [activeFilter, collectedFilter, messageApi, moduleFilter, productFilter, searchQuery, statusFilter])
useEffect(() => {
void fetchData()
}, [fetchData])
useEffect(() => {
const visibleKeys = new Set(allSources.map((source) => source.key))
setSelectedRowKeys((keys) => keys.filter((key) => visibleKeys.has(String(key))))
}, [allSources])
useEffect(() => {
const updateHeight = () => {
setTableHeight(Math.max(260, (tableRegionRef.current?.offsetHeight || 0) - 56))
@@ -332,8 +396,15 @@ function DataSources() {
const handleTriggerAll = async () => {
try {
setTriggerAllLoading(true)
const res = await axios.post('/api/v1/datasources/trigger-all', null, {
params: { force: forceTriggerAll },
const res = await axios.post('/api/v1/datasources/trigger-batch', {
source_ids: selectedSourceIds,
force: forceTriggerAll,
product: selectedSourceIds.length ? undefined : productFilter === 'all' ? undefined : productFilter,
module: selectedSourceIds.length ? undefined : moduleFilter === 'all' ? undefined : moduleFilter,
is_active: selectedSourceIds.length ? undefined : activeFilter === 'all' ? undefined : activeFilter === 'enabled',
run_status: selectedSourceIds.length ? undefined : statusFilter === 'all' ? undefined : statusFilter,
collected: selectedSourceIds.length ? undefined : collectedFilter === 'all' ? undefined : collectedFilter === 'collected',
q: selectedSourceIds.length ? undefined : searchQuery.trim() || undefined,
})
const triggered = res.data.triggered || []
const skipped = res.data.skipped || []
@@ -343,10 +414,11 @@ function DataSources() {
skipped.length ? `跳过 ${skipped.length}` : null,
failed.length ? `失败 ${failed.length}` : null,
].filter(Boolean).join(''))
setSelectedRowKeys([])
void fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || '触发失败')
messageApi.error(err.response?.data?.detail || '批量触发失败')
} finally {
setTriggerAllLoading(false)
}
@@ -431,6 +503,15 @@ function DataSources() {
width: 100,
render: () => <Tag color="blue"></Tag>,
},
{
title: '产品域',
key: 'product',
width: 110,
render: (_: unknown, record: UnifiedDataSource) => {
const product = record.product || 'other'
return <Tag color={PRODUCT_TAG_COLORS[product] || 'default'}>{PRODUCT_LABELS[product] || product}</Tag>
},
},
{
title: '层级/类型',
key: 'module',
@@ -451,6 +532,16 @@ function DataSources() {
width: 180,
render: (value: string | null | undefined) => formatDateTimeZhCN(value) || '-',
},
{
title: '已采集',
key: 'collected',
width: 110,
render: (_: unknown, record: UnifiedDataSource) => (
<Tag color={record.has_collected_data ? 'success' : 'default'}>
{record.has_collected_data ? `${record.collected_records || 0}` : '未采集'}
</Tag>
),
},
{
title: '状态',
key: 'status',
@@ -513,7 +604,7 @@ function DataSources() {
</div>
<div className="data-source-bulk-toolbar__stats">
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span>
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{allSources.length}</strong>
</div>
<div className="data-source-bulk-toolbar__stat-pill">
@@ -521,9 +612,19 @@ function DataSources() {
<strong>{builtInSources.length}</strong>
</div>
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span>
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{activeBuiltInCount}</strong>
</div>
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{collectedBuiltInCount}</strong>
</div>
{selectedSourceIds.length > 0 ? (
<div className="data-source-bulk-toolbar__stat-pill data-source-bulk-toolbar__stat-pill--success">
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{selectedSourceIds.length}</strong>
</div>
) : null}
{runningBuiltInCount > 0 ? (
<Tooltip title="查看采集中任务">
<button
@@ -548,18 +649,93 @@ function DataSources() {
</Checkbox>
<Button type="primary" size="middle" icon={<SyncOutlined />} loading={triggerAllLoading} onClick={handleTriggerAll}>
{selectedSourceIds.length ? '采集选中项' : '采集当前筛选'}
</Button>
</Space>
</div>
<div className="data-source-filter-toolbar">
<Select
className="data-source-filter-toolbar__control"
value={productFilter}
onChange={setProductFilter}
options={[
{ value: 'all', label: '全部产品域' },
{ value: 'vessels', label: '船只' },
{ value: 'cables', label: '海底光缆' },
{ value: 'satellites', label: '卫星' },
{ value: 'bgp', label: 'BGP' },
{ value: 'compute', label: '算力' },
{ value: 'ai', label: 'AI' },
{ value: 'media', label: '媒体' },
{ value: 'other', label: '其他' },
]}
/>
<Select
className="data-source-filter-toolbar__control"
value={moduleFilter}
onChange={setModuleFilter}
options={[
{ value: 'all', label: '全部层级' },
{ value: 'L1', label: 'L1' },
{ value: 'L2', label: 'L2' },
{ value: 'L3', label: 'L3' },
{ value: 'L4', label: 'L4' },
]}
/>
<Select
className="data-source-filter-toolbar__control"
value={activeFilter}
onChange={setActiveFilter}
options={[
{ value: 'enabled', label: '已启用' },
{ value: 'disabled', label: '已禁用' },
{ value: 'all', label: '全部启用状态' },
]}
/>
<Select
className="data-source-filter-toolbar__control"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: 'all', label: '全部执行状态' },
{ value: 'success', label: '最近成功' },
{ value: 'failed', label: '最近失败' },
{ value: 'running', label: '采集中' },
{ value: 'not_run', label: '未执行' },
]}
/>
<Select
className="data-source-filter-toolbar__control"
value={collectedFilter}
onChange={setCollectedFilter}
options={[
{ value: 'all', label: '全部数据状态' },
{ value: 'collected', label: '已采集' },
{ value: 'uncollected', label: '未采集' },
]}
/>
<Input.Search
className="data-source-filter-toolbar__search"
allowClear
placeholder="搜索名称、标识或采集器"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region">
<Table
columns={columns}
dataSource={allSources}
rowKey="key"
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
preserveSelectedRowKeys: false,
columnWidth: 40,
}}
loading={loading}
pagination={false}
scroll={{ x: 1100, y: tableHeight }}
scroll={{ x: 1350, y: tableHeight }}
tableLayout="fixed"
size="small"
/>

View File

@@ -149,6 +149,10 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 },
en: { title: 'AI Provider Guide', group: 'Agents', order: 40 },
},
'ops-runbook.md': {
zh: { title: 'Planet 运维手册', group: 'Ops', order: 49 },
en: { title: 'Planet Ops Runbook', group: 'Ops', order: 49 },
},
'ops-docker-compose-buildx-upgrade.md': {
zh: { title: 'Docker + Compose + Buildx 升级', group: 'Ops', order: 50 },
en: { title: 'Docker + Compose + Buildx Upgrade', group: 'Ops', order: 50 },

View File

@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { Button, Form, Input, message, Typography } from 'antd'
import { LockOutlined, MailOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
interface ErrorBody {
response?: {
data?: { detail?: string | { code?: string; message?: string; retry_after_seconds?: number } }
}
}
function extractDetail(error: unknown): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败'
return '操作失败'
}
function ForgotPassword() {
const navigate = useNavigate()
const [step, setStep] = useState<'request' | 'reset'>('request')
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [cooldown, setCooldown] = useState(0)
useEffect(() => {
if (cooldown <= 0) return
const timer = setTimeout(() => setCooldown((v) => v - 1), 1000)
return () => clearTimeout(timer)
}, [cooldown])
const onRequest = async (values: { email: string }) => {
setLoading(true)
try {
await axios.post(`${API_URL}/auth/forgot-password`, { email: values.email })
setEmail(values.email)
setStep('reset')
setCooldown(60)
message.success('若该邮箱已注册,验证码已发送。请到邮箱查收。')
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onReset = async (values: { code: string; new_password: string }) => {
setLoading(true)
try {
await axios.post(`${API_URL}/auth/reset-password`, {
email,
code: values.code,
new_password: values.new_password,
})
message.success('密码已重置,请用新密码登录')
navigate('/login')
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onResend = async () => {
if (!email || cooldown > 0) return
try {
await axios.post(`${API_URL}/auth/forgot-password`, { email })
setCooldown(60)
message.success('验证码已重发')
} catch (error) {
message.error(extractDetail(error))
}
}
return (
<div className="login-container">
<div className="login-box">
<h1 style={{ textAlign: 'center', marginBottom: 24 }}></h1>
{step === 'request' ? (
<Form name="forgot" onFinish={onRequest} layout="vertical">
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input prefix={<MailOutlined />} placeholder="注册时使用的邮箱" size="large" autoComplete="email" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
<div style={{ textAlign: 'center' }}>
<Typography.Link onClick={() => navigate('/login')}></Typography.Link>
</div>
</Form>
) : (
<Form name="reset" onFinish={onReset} layout="vertical">
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
<b>{email}</b>10
</Typography.Paragraph>
<Form.Item
name="code"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为 6 位数字' },
]}
>
<Input prefix={<SafetyCertificateOutlined />} placeholder="6 位验证码" size="large" maxLength={6} />
</Form.Item>
<Form.Item
name="new_password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="新密码 (至少 8 位)" size="large" autoComplete="new-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography.Link onClick={() => setStep('request')}></Typography.Link>
<Button type="link" size="small" disabled={cooldown > 0} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
</Button>
</div>
</Form>
)}
</div>
</div>
)
}
export default ForgotPassword

View File

@@ -1,9 +1,16 @@
import { useState } from 'react'
import { Input, Button, Form, message } from 'antd'
import { UserOutlined, LockOutlined } from '@ant-design/icons'
import { Button, Form, Input, Typography, message } from 'antd'
import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
interface LoginError {
response?: {
data?: { detail?: string | { code?: string; email?: string; message?: string } }
status?: number
}
}
function Login() {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
@@ -16,8 +23,16 @@ function Login() {
message.success('登录成功')
navigate('/admin', { replace: true })
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || '登录失败')
const err = error as LoginError
const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.code === 'EMAIL_NOT_VERIFIED') {
message.warning('邮箱未验证,请先完成邮箱验证')
const email = detail.email ? `?email=${encodeURIComponent(detail.email)}` : ''
navigate(`/verify-email${email}`)
return
}
const fallback = typeof detail === 'string' ? detail : detail?.message
message.error(fallback || '登录失败')
} finally {
setLoading(false)
}
@@ -36,6 +51,7 @@ function Login() {
prefix={<UserOutlined />}
placeholder="用户名"
size="large"
autoComplete="username"
/>
</Form.Item>
<Form.Item
@@ -46,6 +62,7 @@ function Login() {
prefix={<LockOutlined />}
placeholder="密码"
size="large"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item>
@@ -59,6 +76,10 @@ function Login() {
</Button>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography.Link onClick={() => navigate('/register')}></Typography.Link>
<Typography.Link onClick={() => navigate('/forgot-password')}></Typography.Link>
</div>
</Form>
</div>
</div>

View File

@@ -225,6 +225,7 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
const [helpExpanded, setHelpExpanded] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false)
const [servicePanelOpen, setServicePanelOpen] = useState(false)
const [helpPanelOpen, setHelpPanelOpen] = useState(false)
const [selectedPresetKey, setSelectedPresetKey] = useState<string>(PLAYGROUND_PRESETS[0].key)
const [title, setTitle] = useState(PLAYGROUND_PRESETS[0].values.title)
const [objective, setObjective] = useState(PLAYGROUND_PRESETS[0].values.objective)
@@ -582,14 +583,16 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
className="playground-card playground-card--provider"
title="Provider 状态"
extra={(
<Button
type="text"
shape="circle"
icon={<SyncOutlined spin={statusLoading} />}
onClick={() => void loadProviderStatus(true)}
aria-label="刷新 Provider 状态"
className="playground-card__icon-button"
/>
<Tooltip title="刷新 Provider 状态">
<Button
type="text"
shape="circle"
icon={<SyncOutlined spin={statusLoading} />}
onClick={() => void loadProviderStatus(true)}
aria-label="刷新 Provider 状态"
className="playground-card__icon-button"
/>
</Tooltip>
)}
>
<Scrollbar className="playground-card__scroll">
@@ -702,7 +705,6 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
<div className="playground-shell">
<div className="playground-shell__sidebar">
{providerStatusPanel}
{helpPanel}
</div>
<Card
@@ -719,6 +721,16 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
className="playground-card__icon-button"
/>
</Tooltip>
<Tooltip title="测试说明">
<Button
type="text"
shape="circle"
icon={<InfoCircleOutlined />}
onClick={() => setHelpPanelOpen(true)}
aria-label="测试说明"
className="playground-card__icon-button"
/>
</Tooltip>
<Tooltip title="设置">
<Button
type="text"
@@ -1015,6 +1027,18 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
>
<div className="playground-service-modal__body">
{providerStatusPanel}
</div>
</Modal>
<Modal
title="测试说明"
open={helpPanelOpen}
onCancel={() => setHelpPanelOpen(false)}
footer={null}
width={560}
className="playground-service-modal"
>
<div className="playground-service-modal__body">
{helpPanel}
</div>
</Modal>

View File

@@ -0,0 +1,195 @@
import { useEffect, useState } from 'react'
import { Button, Form, Input, message, Typography } from 'antd'
import { LockOutlined, MailOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
interface RegisterFormValues {
username: string
email: string
password: string
}
interface VerifyFormValues {
code: string
}
interface ErrorBody {
response?: {
data?: {
detail?: string | { code?: string; message?: string; retry_after_seconds?: number }
}
status?: number
}
}
function extractDetail(error: unknown): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') {
return detail.message || detail.code || '操作失败'
}
return '操作失败'
}
function RESEND_COOLDOWN(): number {
return 60
}
function Register() {
const navigate = useNavigate()
const [step, setStep] = useState<'register' | 'verify'>('register')
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [resending, setResending] = useState(false)
const [cooldown, setCooldown] = useState(0)
useEffect(() => {
if (cooldown <= 0) return
const timer = setTimeout(() => setCooldown((value) => value - 1), 1000)
return () => clearTimeout(timer)
}, [cooldown])
const onRegister = async (values: RegisterFormValues) => {
setLoading(true)
try {
await axios.post(`${API_URL}/auth/register`, values)
setEmail(values.email)
setStep('verify')
setCooldown(RESEND_COOLDOWN())
message.success('验证码已发送到邮箱')
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onVerify = async (values: VerifyFormValues) => {
setLoading(true)
try {
const response = await axios.post(`${API_URL}/auth/verify-email`, {
email,
code: values.code,
})
const { access_token, user } = response.data
useAuthStore.setState({ token: access_token, user })
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
message.success('邮箱验证成功,正在登录…')
navigate('/admin', { replace: true })
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onResend = async () => {
if (cooldown > 0 || !email) return
setResending(true)
try {
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
setCooldown(RESEND_COOLDOWN())
message.success('验证码已重发')
} catch (error) {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.retry_after_seconds) {
setCooldown(detail.retry_after_seconds)
}
message.error(extractDetail(error))
} finally {
setResending(false)
}
}
return (
<div className="login-container">
<div className="login-box">
<h1 style={{ textAlign: 'center', marginBottom: 24 }}></h1>
{step === 'register' ? (
<Form name="register" onFinish={onRegister} layout="vertical">
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 50, message: '用户名长度 3-50' },
]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" size="large" autoComplete="username" />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input prefix={<MailOutlined />} placeholder="邮箱" size="large" autoComplete="email" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码 (至少 8 位)"
size="large"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
<div style={{ textAlign: 'center' }}>
<Typography.Link onClick={() => navigate('/login')}></Typography.Link>
</div>
</Form>
) : (
<Form name="verify" onFinish={onVerify} layout="vertical">
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
6 <b>{email}</b>10
</Typography.Paragraph>
<Form.Item
name="code"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为 6 位数字' },
]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="6 位验证码"
size="large"
maxLength={6}
inputMode="numeric"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography.Link onClick={() => setStep('register')}></Typography.Link>
<Button type="link" size="small" disabled={cooldown > 0 || resending} loading={resending} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
</Button>
</div>
</Form>
)}
</div>
</div>
)
}
export default Register

View File

@@ -18,6 +18,7 @@ import {
} from '@ant-design/icons'
import {
Alert,
AutoComplete,
Button,
Card,
Checkbox,
@@ -37,11 +38,13 @@ import {
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import ConnectionTestInput, { PlugConnectIcon } from '../../components/ConnectionTestInput/ConnectionTestInput'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { useNavigate, useSearchParams } from 'react-router-dom'
import SmtpPanel from './SmtpPanel'
const { Title, Text } = Typography
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
@@ -318,62 +321,6 @@ const formatLagSeconds = (value: number | null | undefined) => {
return `${Math.round(value / 3600)} 小时`
}
function PlugConnectIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 12l5 5l-1.5 1.5a3.536 3.536 0 1 1 -5 -5l1.5 -1.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M17 12l-5 -5l1.5 -1.5a3.536 3.536 0 1 1 5 5l-1.5 1.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 21l2.5 -2.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18.5 5.5l2.5 -2.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 11l-2 2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 14l-2 2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function SettingsPanel({
loading,
children,
@@ -441,6 +388,7 @@ function Settings() {
const [tvEditForm] = Form.useForm<TVStreamSource>()
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], integrationForm)
const webSearchEnabled = Form.useWatch(['web_search', 'enabled'], integrationForm)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
: integrations?.ai_provider.api_key
@@ -1907,6 +1855,11 @@ function Settings() {
</SettingsPanel>
),
},
{
key: 'smtp',
label: 'SMTP 邮件',
children: <SmtpPanel />,
},
{
key: 'tv',
label: '电视直播',
@@ -2036,42 +1989,43 @@ function Settings() {
>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
<Select
showSearch
optionFilterProp="label"
<AutoComplete
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
options={aiProviderPresets.map((preset) => ({
value: preset.provider,
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => {
onSelect={(value) => {
applyAiProviderSelection(value)
}}
placeholder="选择或输入 provider id例如 openai"
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button
icon={<SyncOutlined />}
loading={refreshingAiPreset}
onClick={refreshSelectedAiProviderPreset}
>
</Button>
<Button
icon={<PlayCircleOutlined />}
loading={testingAiProviderConnection}
onClick={() => { void testAiProviderConnection() }}
>
</Button>
</Space>
</Form.Item>
</div>
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
<ConnectionTestInput
placeholder="https://api.example.com/v1"
testing={testingAiProviderConnection}
testDisabled={!selectedAiProvider}
onTest={() => { void testAiProviderConnection() }}
extraSuffix={(
<Tooltip title="刷新当前 Provider 的模型配置">
<Button
type="text"
size="small"
icon={<SyncOutlined spin={refreshingAiPreset} />}
disabled={!selectedAiProvider}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void refreshSelectedAiProviderPreset()
}}
aria-label="刷新当前 Provider 的模型配置"
/>
</Tooltip>
)}
/>
</Form.Item>
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
<Select>
@@ -2082,13 +2036,12 @@ function Settings() {
</Form.Item>
<Form.Item name={['ai_provider', 'model']} label="默认模型">
<Select
showSearch
optionFilterProp="label"
<AutoComplete
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
options={(
aiProviderPresets.find((preset) => preset.provider === selectedAiProvider)?.models || []
).map((model) => ({ value: model, label: model }))}
dropdownRender={(menu) => menu}
placeholder="选择或输入模型名,例如 gpt-5.1"
/>
</Form.Item>
@@ -2105,13 +2058,15 @@ function Settings() {
autoComplete="new-password"
placeholder="输入新的 LLM API key"
suffix={(
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
<Tooltip title={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}>
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -2148,13 +2103,15 @@ function Settings() {
autoComplete="new-password"
placeholder="输入新的代理 token"
suffix={(
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
<Tooltip title={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}>
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -2171,6 +2128,7 @@ function Settings() {
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
<Select
showSearch
disabled={!webSearchEnabled}
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
@@ -2182,18 +2140,15 @@ function Settings() {
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<Input placeholder="https://api.tavily.com" />
</Form.Item>
<Form.Item label=" ">
<Button
icon={<PlayCircleOutlined />}
loading={testingWebSearchConnection}
onClick={() => { void testWebSearchConnection() }}
>
</Button>
<ConnectionTestInput
placeholder="https://api.tavily.com"
disabled={!webSearchEnabled}
testing={testingWebSearchConnection}
testDisabled={!webSearchEnabled || !selectedWebSearchProvider}
onTest={() => { void testWebSearchConnection() }}
/>
</Form.Item>
</div>
@@ -2208,15 +2163,19 @@ function Settings() {
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
disabled={!webSearchEnabled}
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Button
type="text"
size="small"
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
<Tooltip title={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}>
<Button
type="text"
size="small"
disabled={!webSearchEnabled}
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -2225,43 +2184,43 @@ function Settings() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
<InputNumber min={1} max={20} disabled={!webSearchEnabled} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={120} style={{ width: '100%' }} />
<InputNumber min={3} max={120} disabled={!webSearchEnabled} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
<Input placeholder="/search" />
<Input disabled={!webSearchEnabled} placeholder="/search" />
</Form.Item>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input placeholder="basic" />
<Input disabled={!webSearchEnabled} placeholder="basic" />
</Form.Item>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input placeholder="google" />
<Input disabled={!webSearchEnabled} placeholder="google" />
</Form.Item>
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
<Input placeholder="general" />
<Input disabled={!webSearchEnabled} placeholder="general" />
</Form.Item>
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input placeholder="/v2/search" />
<Input disabled={!webSearchEnabled} placeholder="/v2/search" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input placeholder="/v2/scrape" />
<Input disabled={!webSearchEnabled} placeholder="/v2/scrape" />
</Form.Item>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Answer</Checkbox>
<Checkbox disabled={!webSearchEnabled}> Answer</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Raw Content</Checkbox>
<Checkbox disabled={!webSearchEnabled}> Raw Content</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Result Text</Checkbox>
<Checkbox disabled={!webSearchEnabled}> Result Text</Checkbox>
</Form.Item>
</Space>
</Card>

View File

@@ -0,0 +1,208 @@
import { useEffect, useState } from 'react'
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Space, Switch, Typography, message } from 'antd'
import axios from 'axios'
interface SecretStatus {
configured: boolean
preview: string
source?: string
}
interface SmtpSettingsResponse {
host: string
port: number
username: string
password: SecretStatus
from_address: string
from_name: string
use_tls: boolean
use_starttls: boolean
timeout_seconds: number
configured: boolean
}
interface SmtpFormValues {
host: string
port: number
username: string
password: string
from_address: string
from_name: string
use_tls: boolean
use_starttls: boolean
timeout_seconds: number
}
interface ErrorBody {
response?: { data?: { detail?: string | { code?: string; message?: string } } }
}
function extractDetail(error: unknown): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败'
return '操作失败'
}
export default function SmtpPanel() {
const [form] = Form.useForm<SmtpFormValues>()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testOpen, setTestOpen] = useState(false)
const [testTo, setTestTo] = useState('')
const [data, setData] = useState<SmtpSettingsResponse | null>(null)
const load = async () => {
setLoading(true)
try {
const response = await axios.get('/api/v1/settings/smtp')
const smtp = response.data.smtp as SmtpSettingsResponse
setData(smtp)
form.setFieldsValue({
host: smtp.host,
port: smtp.port,
username: smtp.username,
password: smtp.password.configured ? smtp.password.preview : '',
from_address: smtp.from_address,
from_name: smtp.from_name,
use_tls: smtp.use_tls,
use_starttls: smtp.use_starttls,
timeout_seconds: smtp.timeout_seconds,
})
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onSave = async (values: SmtpFormValues) => {
setSaving(true)
try {
const payload: Record<string, unknown> = { ...values }
// If the user did not touch the masked password, send empty so the backend keeps the current value.
if (data?.password.configured && values.password === data.password.preview) {
payload.password = ''
}
const response = await axios.put('/api/v1/settings/smtp', payload)
setData(response.data.smtp)
const smtp = response.data.smtp as SmtpSettingsResponse
form.setFieldsValue({
...values,
password: smtp.password.configured ? smtp.password.preview : '',
})
message.success('SMTP 设置已保存')
} catch (error) {
message.error(extractDetail(error))
} finally {
setSaving(false)
}
}
const onTest = async () => {
if (!testTo) {
message.warning('请填写收件地址')
return
}
setTesting(true)
try {
const values = form.getFieldsValue()
const payload: Record<string, unknown> = { ...values }
if (data?.password.configured && values.password === data.password.preview) {
payload.password = ''
}
const response = await axios.post('/api/v1/settings/smtp/test', {
to: testTo,
settings: payload,
})
if (response.data.success) {
message.success('测试邮件已发送')
setTestOpen(false)
} else {
message.error(response.data.message || '测试发送失败')
}
} catch (error) {
message.error(extractDetail(error))
} finally {
setTesting(false)
}
}
return (
<Card loading={loading} className="settings-panel-card">
{!data?.configured && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="尚未配置 SMTP公开注册和邮箱验证暂不可用。"
description="配置主机、发件地址和(如需要)账号密码,保存后通过下方测试发送验证。"
/>
)}
<Form form={form} layout="vertical" onFinish={onSave}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="host" label="SMTP 主机" rules={[{ required: true, message: '请输入 SMTP 主机' }]}>
<Input placeholder="smtp.example.com" />
</Form.Item>
<Form.Item name="port" label="端口" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="username" label="账号">
<Input placeholder="发件账户用户名" autoComplete="off" />
</Form.Item>
<Form.Item name="password" label="密码">
<Input.Password placeholder="保存的密码会以 *** 显示,留空或保持不变以保留原密码" autoComplete="new-password" />
</Form.Item>
<Form.Item name="from_address" label="发件地址" rules={[{ required: true, type: 'email' }]}>
<Input placeholder="noreply@example.com" />
</Form.Item>
<Form.Item name="from_name" label="发件人名称">
<Input placeholder="Planet" />
</Form.Item>
<Form.Item name="use_starttls" label="STARTTLS (端口 587)" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="use_tls" label="隐式 TLS (端口 465)" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="timeout_seconds" label="超时 (秒)">
<InputNumber min={3} max={300} style={{ width: '100%' }} />
</Form.Item>
</div>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
STARTTLS TLS 587 465
</Typography.Paragraph>
<Space>
<Button type="primary" htmlType="submit" loading={saving}> SMTP </Button>
<Button onClick={() => setTestOpen(true)}></Button>
</Space>
</Form>
<Modal
title="发送测试邮件"
open={testOpen}
onCancel={() => setTestOpen(false)}
onOk={onTest}
okText="发送"
okButtonProps={{ loading: testing }}
cancelText="取消"
>
<Typography.Paragraph type="secondary">
SMTP
</Typography.Paragraph>
<Input
placeholder="收件地址"
value={testTo}
onChange={(event) => setTestTo(event.target.value)}
autoFocus
/>
</Modal>
</Card>
)
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { Button, Form, Input, message, Typography } from 'antd'
import { MailOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import axios from 'axios'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
interface ErrorBody {
response?: {
data?: { detail?: string | { code?: string; message?: string; retry_after_seconds?: number } }
}
}
function extractDetail(error: unknown): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败'
return '操作失败'
}
function VerifyEmail() {
const navigate = useNavigate()
const [search] = useSearchParams()
const [email, setEmail] = useState(search.get('email') || '')
const [loading, setLoading] = useState(false)
const [resending, setResending] = useState(false)
const [cooldown, setCooldown] = useState(0)
useEffect(() => {
if (cooldown <= 0) return
const timer = setTimeout(() => setCooldown((v) => v - 1), 1000)
return () => clearTimeout(timer)
}, [cooldown])
const onVerify = async (values: { code: string }) => {
setLoading(true)
try {
const response = await axios.post(`${API_URL}/auth/verify-email`, { email, code: values.code })
const { access_token, user } = response.data
useAuthStore.setState({ token: access_token, user })
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
message.success('邮箱验证成功,正在登录…')
navigate('/admin', { replace: true })
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onResend = async () => {
if (!email || cooldown > 0) return
setResending(true)
try {
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
setCooldown(60)
message.success('验证码已重发')
} catch (error) {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.retry_after_seconds) {
setCooldown(detail.retry_after_seconds)
}
message.error(extractDetail(error))
} finally {
setResending(false)
}
}
return (
<div className="login-container">
<div className="login-box">
<h1 style={{ textAlign: 'center', marginBottom: 24 }}></h1>
<Form name="verify-email" onFinish={onVerify} layout="vertical">
<Form.Item label="邮箱" required>
<Input
prefix={<MailOutlined />}
size="large"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="注册时使用的邮箱"
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="code"
label="验证码"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为 6 位数字' },
]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="6 位验证码"
size="large"
maxLength={6}
inputMode="numeric"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading} disabled={!email}>
</Button>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography.Link onClick={() => navigate('/login')}></Typography.Link>
<Button type="link" size="small" disabled={cooldown > 0 || resending || !email} loading={resending} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
</Button>
</div>
</Form>
</div>
</div>
)
}
export default VerifyEmail

View File

@@ -1,6 +1,6 @@
[project]
name = "planet"
version = "0.51.1"
version = "0.52.0"
description = "智能星球计划 - 态势感知系统"
requires-python = ">=3.14"
dependencies = [
@@ -21,6 +21,7 @@ dependencies = [
"aiofiles>=23.2.1",
"python-dotenv>=1.0.0",
"email-validator>=2.1.0",
"aiosmtplib>=3.0.0",
"apscheduler>=3.10.4",
"networkx>=3.0",
"mediapipe>=0.10.35",

View File

@@ -255,6 +255,7 @@ Changing layout, visual hierarchy, controls, interaction states, responsive beha
- Use icons in buttons for common tools/actions when an established icon exists.
- Keep icon-only buttons accessible with `aria-label` and `title`.
- Use segmented controls for modes, switches/checkboxes for binary settings, sliders/inputs for numeric values, menus/selects for option sets, and tabs for views.
- Connection-test actions for endpoint/Base URL inputs must use the shared `ConnectionTestInput` pattern: a single plug/connector icon at the input suffix, no adjacent text button; disabled integrations should grey out the field and its test action.
- Do not put cards inside cards.
- Do not use visible in-app text to explain obvious UI features or styling.
- Text must fit within its parent on mobile and desktop.

13
uv.lock generated
View File

@@ -20,6 +20,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]
[[package]]
name = "aiosmtplib"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/ad/240a7ce4e50713b111dff8b781a898d8d4770e5d6ad4899103f84c86005c/aiosmtplib-5.1.0.tar.gz", hash = "sha256:2504a23b2b63c9de6bc4ea719559a38996dba68f73f6af4eb97be20ee4c5e6c4", size = 66176, upload-time = "2026-01-25T01:51:11.408Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/82/70f2c452acd7ed18c558c8ace9a8cf4fdcc70eae9a41749b5bdc53eb6f45/aiosmtplib-5.1.0-py3-none-any.whl", hash = "sha256:368029440645b486b69db7029208a7a78c6691b90d24a5332ddba35d9109d55b", size = 27778, upload-time = "2026-01-25T01:51:10.026Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
@@ -748,10 +757,11 @@ wheels = [
[[package]]
name = "planet"
version = "0.51.1"
version = "0.52.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "aiosmtplib" },
{ name = "apscheduler" },
{ name = "asyncpg" },
{ name = "bcrypt" },
@@ -785,6 +795,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = ">=23.2.1" },
{ name = "aiosmtplib", specifier = ">=3.0.0" },
{ name = "apscheduler", specifier = ">=3.10.4" },
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "bcrypt", specifier = ">=4.0.0" },

View File

@@ -422,25 +422,72 @@ Same as `data_frame` with `update_type: "full"`
## Subscription Management
The current backend accepts `type: "subscribe"` / `type: "unsubscribe"` messages. Older examples that use `type: "subscription"` describe the same intent but should not be used for new clients.
### Subscribe
```json
{
"type": "subscription",
"type": "subscribe",
"timestamp": "2024-01-20T10:30:00.000Z",
"data": {
"action": "subscribe",
"channels": ["gpu_clusters", "alerts"]
}
}
```
### Vessel Viewport Subscribe
The `vessels` channel is viewport-scoped. Clients must provide the current bbox, zoom, and limit. The server stores only this lightweight subscription filter and sends updates that match the subscribed bbox.
```json
{
"type": "subscribe",
"timestamp": "2026-05-12T10:30:00.000Z",
"data": {
"channel": "vessels",
"bbox": [120.8, 30.7, 122.1, 31.8],
"zoom": 12,
"limit": 1000
}
}
```
`limit` is capped at 5000 per subscription, and each WebSocket data frame is capped to 1000 vessel updates. Collector updates are throttled to one flush per second; within a flush window, only the latest update per MMSI is retained.
Vessel data frames use the normal `data_frame` envelope:
```json
{
"type": "data_frame",
"channel": "vessels",
"timestamp": "2026-05-12T10:30:01.000Z",
"payload": {
"action": "upsert",
"vessels": [
{
"mmsi": 257123000,
"lat": 59.91,
"lon": 10.73,
"sog": 12.4,
"cog": 214,
"received_at": "2026-05-12T10:30:00Z"
}
],
"subscription": {
"bbox": [120.8, 30.7, 122.1, 31.8],
"zoom": 12,
"limit": 1000
}
}
}
```
### Unsubscribe
```json
{
"type": "subscription",
"type": "unsubscribe",
"timestamp": "2024-01-20T10:30:00.000Z",
"data": {
"action": "unsubscribe",
"channels": ["alerts"]
}
}