feat: add all application source code

- apps/api: FastAPI backend with Dockerfile
- apps/web: Next.js frontend with Dockerfile
- apps/sensor: Signal collection agent
- packages: shared packages

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-22 18:57:44 +08:00
parent a840bf975b
commit 196d269b92
245 changed files with 42207 additions and 6 deletions

View File

@@ -0,0 +1 @@
"""API Routes"""

View File

@@ -0,0 +1,184 @@
"""
Agent (ClawBot) Endpoints
ADR-005: BFF 架構 - 所有 AI 調用經過 BFF
Phase 1.2: 真實 Ollama 串接
"""
import json
import logging
from datetime import datetime
from typing import Literal
from uuid import UUID, uuid4
import httpx
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
router = APIRouter()
logger = logging.getLogger(__name__)
# ==================== Ollama Config ====================
OLLAMA_BASE_URL = "http://192.168.0.188:11434"
OLLAMA_MODEL = "llama3.2:latest" # 可根據實際部署調整
OLLAMA_TIMEOUT = 120.0 # 串流超時
class ChatRequest(BaseModel):
message: str
conversation_id: UUID | None = None
context: dict | None = None
class SuggestedAction(BaseModel):
id: str
label: str
description: str | None = None
risk_level: Literal["low", "medium", "high", "critical"]
class ChatResponse(BaseModel):
message: str
conversation_id: UUID
actions: list[SuggestedAction] | None = None
requires_approval: bool = False
approval_id: UUID | None = None
class AgentStatus(BaseModel):
status: Literal["idle", "thinking", "executing", "waiting_approval"]
active_conversations: int
current_task: str | None = None
last_activity: datetime | None = None
@router.post("/chat", response_model=ChatResponse)
async def chat_with_agent(request: ChatRequest) -> ChatResponse:
"""與 ClawBot 對話"""
conversation_id = request.conversation_id or uuid4()
# TODO: 實際調用 ClawBot
return ChatResponse(
message=f"收到訊息: {request.message}",
conversation_id=conversation_id,
requires_approval=False,
)
@router.post("/chat/stream")
async def chat_with_agent_stream(request: ChatRequest) -> StreamingResponse:
"""與 ClawBot 對話 (SSE 串流)"""
async def generate():
# TODO: 實際串流
yield "data: Hello from ClawBot\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
)
@router.get("/status", response_model=AgentStatus)
async def get_agent_status() -> AgentStatus:
"""ClawBot 狀態"""
return AgentStatus(
status="idle",
active_conversations=0,
current_task=None,
last_activity=datetime.utcnow(),
)
@router.get("/thinking")
async def get_agent_thinking(
prompt: str = Query(
default="你是 AWOOOI 智能運維助手。請簡短分析一下目前系統的健康狀態,用中文回答。",
description="發送給 AI 的提示詞",
),
model: str = Query(default=OLLAMA_MODEL, description="Ollama 模型名稱"),
) -> StreamingResponse:
"""
ClawBot 思考軌跡 (SSE 串流)
Phase 1.2: 真實串接 Ollama at 192.168.0.188:11434
"""
async def generate_thinking_stream():
"""串接 Ollama 並轉換為 SSE 格式"""
# 1. 開始思考
yield f"data: {json.dumps({'type': 'thinking', 'content': '正在連接 AI 模型...'}, ensure_ascii=False)}\n\n"
try:
async with httpx.AsyncClient(timeout=OLLAMA_TIMEOUT) as client:
# 2. 發送請求到 Ollama
yield f"data: {json.dumps({'type': 'thinking', 'content': f'模型: {model}'}, ensure_ascii=False)}\n\n"
async with client.stream(
"POST",
f"{OLLAMA_BASE_URL}/api/generate",
json={
"model": model,
"prompt": prompt,
"stream": True,
},
) as response:
if response.status_code != 200:
yield f"data: {json.dumps({'type': 'error', 'content': f'Ollama 錯誤: HTTP {response.status_code}'}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return
yield f"data: {json.dumps({'type': 'thinking', 'content': '開始接收 AI 回應...'}, ensure_ascii=False)}\n\n"
# 3. 串流讀取 Ollama 回應
buffer = ""
async for line in response.aiter_lines():
if not line:
continue
try:
chunk = json.loads(line)
token = chunk.get("response", "")
done = chunk.get("done", False)
if token:
# 累積 token每 10 字符或遇到標點符號時發送
buffer += token
if len(buffer) >= 10 or any(p in buffer for p in "。!?,、\n"):
yield f"data: {json.dumps({'type': 'thinking', 'content': buffer}, ensure_ascii=False)}\n\n"
buffer = ""
if done:
# 發送剩餘 buffer
if buffer:
yield f"data: {json.dumps({'type': 'thinking', 'content': buffer}, ensure_ascii=False)}\n\n"
# 發送完成訊息
yield f"data: {json.dumps({'type': 'result', 'content': '分析完成'}, ensure_ascii=False)}\n\n"
break
except json.JSONDecodeError as e:
logger.warning(f"JSON 解析失敗: {line[:100]}... - {e}")
continue
except httpx.ConnectError as e:
logger.error(f"無法連接 Ollama: {e}")
yield f"data: {json.dumps({'type': 'error', 'content': f'無法連接 Ollama ({OLLAMA_BASE_URL})'}, ensure_ascii=False)}\n\n"
except httpx.TimeoutException as e:
logger.error(f"Ollama 超時: {e}")
yield f"data: {json.dumps({'type': 'error', 'content': '請求超時'}, ensure_ascii=False)}\n\n"
except Exception as e:
logger.error(f"未知錯誤: {e}")
yield f"data: {json.dumps({'type': 'error', 'content': f'未知錯誤: {str(e)}'}, ensure_ascii=False)}\n\n"
# 4. 結束標記
yield "data: [DONE]\n\n"
return StreamingResponse(
generate_thinking_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # 禁用 Nginx 緩衝
},
)

View File

@@ -0,0 +1,477 @@
"""
Approval (HITL) Endpoints
Phase 2.2: Dry-Run 預演 API
Phase 2.3: Multi-Sig 多重簽核 API
"""
from datetime import datetime, timedelta
from typing import Literal
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from src.services.dry_run import dry_run_engine
from src.services.approval import (
multi_sig_engine,
RISK_MATRIX,
InsufficientPermissionError,
DuplicateSignatureError,
TOCTOUConflictError,
ApprovalNotFoundError,
ApprovalAlreadyDecidedError,
)
router = APIRouter()
class PendingAction(BaseModel):
plugin_id: str
operation: str
parameters: dict
risk_level: Literal["low", "medium", "high", "critical"]
dry_run_result: dict | None = None
class Approval(BaseModel):
id: UUID
type: str
status: Literal["pending", "approved", "rejected", "expired"]
action: PendingAction
requested_at: datetime
expires_at: datetime
decided_at: datetime | None = None
decided_by: str | None = None
reason: str | None = None
class ApprovalDecision(BaseModel):
reason: str | None = None
modified_parameters: dict | None = None
class ApprovalList(BaseModel):
items: list[Approval]
next_page_token: str | None = None
# ==================== Dry-Run Models ====================
class DryRunCheckResponse(BaseModel):
"""單項檢查結果"""
name: str
passed: bool
message: str | None = None
class BlastRadiusResponse(BaseModel):
"""爆炸半徑"""
affected_pods: int
estimated_downtime: str
related_services: list[str]
data_impact: Literal["NONE", "READ_ONLY", "WRITE", "DESTRUCTIVE"]
class DryRunResponse(BaseModel):
"""Dry-Run 完整結果 (對應前端 ApprovalCard)"""
checks: list[DryRunCheckResponse]
blast_radius: BlastRadiusResponse
overall_passed: bool
risk_level: Literal["low", "medium", "high", "critical"]
# ==================== Multi-Sig Models (Phase 2.3) ====================
class SignatureRequest(BaseModel):
"""簽章請求"""
user_id: str
user_role: str # "admin", "devops", "cto", "ciso"
comment: str | None = None
class SignerInfo(BaseModel):
"""簽章者資訊"""
user_id: str
role: str
signed_at: datetime
class SignatureStatusResponse(BaseModel):
"""簽章狀態回應"""
approval_id: str
risk_level: str
status: str
current_signatures: int
required_signatures: int
has_required_role: bool
required_roles: list[str]
signers: list[SignerInfo]
class MultiSigApproveResponse(BaseModel):
"""Multi-Sig 簽核回應"""
approval_id: str
status: str
message: str
current_signatures: int
required_signatures: int
needs_more: bool
signers: list[SignerInfo]
class TOCTOUErrorResponse(BaseModel):
"""TOCTOU 衝突回應"""
error: str
reason: str
failed_checks: list[str]
signatures_cleared: bool
# In-memory storage
_approvals: dict[UUID, Approval] = {}
@router.get("", response_model=ApprovalList)
async def list_approvals(
status: Literal["pending", "approved", "rejected", "expired"] | None = None,
) -> ApprovalList:
"""列出待授權項目"""
items = list(_approvals.values())
if status:
items = [a for a in items if a.status == status]
return ApprovalList(items=items)
@router.get("/{approval_id}", response_model=Approval)
async def get_approval(approval_id: UUID) -> Approval:
"""取得授權項目詳情"""
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
return _approvals[approval_id]
@router.post("/{approval_id}/approve", response_model=MultiSigApproveResponse)
async def approve_approval(
approval_id: UUID,
request: SignatureRequest,
) -> MultiSigApproveResponse:
"""
Multi-Sig 簽核 (Phase 2.3)
提交簽章到指定的審批項目。
根據風險等級,可能需要多個簽章才能完成審批。
風險矩陣:
- low: 自動執行
- medium: 需要 1 位 admin/devops
- high: 需要 2 位管理員
- critical: 需要 2 人,含 CTO 或 CISO
⚠️ TOCTOU 防護:
當簽章達到閾值時,會自動重新執行 Dry-Run。
如果資源狀態已改變,將回傳 409 Conflict 並清空所有簽章。
"""
# 確保 Approval 存在於舊系統
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
approval = _approvals[approval_id]
# 同步到 Multi-Sig 引擎 (如果還沒有)
try:
multi_sig_engine.get_approval(approval_id)
except ApprovalNotFoundError:
multi_sig_engine.create_approval(
approval_id=approval_id,
operation=approval.action.operation,
parameters=approval.action.parameters,
risk_level=approval.action.risk_level,
)
# 執行簽核
try:
state = multi_sig_engine.approve_request(
approval_id=approval_id,
user_id=request.user_id,
user_role=request.user_role,
comment=request.comment,
)
# 同步狀態回舊系統
if state.status.value == "approved":
approval.status = "approved"
approval.decided_at = state.executed_at
requirement = RISK_MATRIX[state.risk_level]
return MultiSigApproveResponse(
approval_id=str(approval_id),
status=state.status.value,
message=(
"Approval complete - executing action"
if state.status.value == "approved"
else f"Signature recorded ({len(state.signatures)}/{requirement.min_signatures})"
),
current_signatures=len(state.signatures),
required_signatures=requirement.min_signatures,
needs_more=len(state.signatures) < requirement.min_signatures,
signers=[
SignerInfo(
user_id=sig.user_id,
role=sig.user_role.value,
signed_at=sig.signed_at,
)
for sig in state.signatures
],
)
except InsufficientPermissionError as e:
raise HTTPException(
status_code=403,
detail={
"error": "Insufficient permission",
"role": e.role,
"required_roles": e.required_roles,
},
)
except DuplicateSignatureError as e:
raise HTTPException(
status_code=409,
detail={
"error": "Duplicate signature",
"user_id": e.user_id,
},
)
except ApprovalAlreadyDecidedError as e:
raise HTTPException(
status_code=400,
detail={"error": str(e)},
)
except TOCTOUConflictError as e:
# ⚠️ TOCTOU 衝突 - 資源狀態已改變
raise HTTPException(
status_code=409,
detail={
"error": "TOCTOU Conflict",
"reason": e.reason,
"failed_checks": e.failed_checks,
"signatures_cleared": True,
},
)
@router.post("/{approval_id}/reject", response_model=Approval)
async def reject_approval(approval_id: UUID, decision: ApprovalDecision) -> Approval:
"""拒絕授權"""
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
approval = _approvals[approval_id]
approval.status = "rejected"
approval.decided_at = datetime.utcnow()
approval.reason = decision.reason
# 同步到 Multi-Sig 引擎
try:
multi_sig_engine.reject_request(
approval_id=approval_id,
user_id="system",
user_role="admin",
reason=decision.reason,
)
except (ApprovalNotFoundError, ApprovalAlreadyDecidedError):
pass # 忽略,舊系統已處理
return approval
@router.get("/{approval_id}/signatures", response_model=SignatureStatusResponse)
async def get_signature_status(approval_id: UUID) -> SignatureStatusResponse:
"""
取得簽章狀態 (Phase 2.3)
回傳目前有多少簽章、還需要多少、已簽核者列表等資訊
"""
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
approval = _approvals[approval_id]
# 確保同步到 Multi-Sig 引擎
try:
multi_sig_engine.get_approval(approval_id)
except ApprovalNotFoundError:
multi_sig_engine.create_approval(
approval_id=approval_id,
operation=approval.action.operation,
parameters=approval.action.parameters,
risk_level=approval.action.risk_level,
)
status = multi_sig_engine.get_signature_status(approval_id)
return SignatureStatusResponse(
approval_id=status["approval_id"],
risk_level=status["risk_level"],
status=status["status"],
current_signatures=status["current_signatures"],
required_signatures=status["required_signatures"],
has_required_role=status["has_required_role"],
required_roles=status["required_roles"],
signers=[
SignerInfo(
user_id=s["user_id"],
role=s["role"],
signed_at=datetime.fromisoformat(s["signed_at"]),
)
for s in status["signers"]
],
)
@router.get("/{approval_id}/dry-run", response_model=DryRunResponse)
async def run_dry_run(approval_id: UUID) -> DryRunResponse:
"""
執行 Dry-Run 預演檢查
Phase 2.2: 回傳 ApprovalCard 所需的 dryRunChecks 格式
- RBAC 權限檢查
- 語法正確性
- 資源存在性
- 爆炸半徑評估
"""
if approval_id not in _approvals:
raise HTTPException(status_code=404, detail="Approval not found")
approval = _approvals[approval_id]
action = approval.action
# 執行 Dry-Run 引擎
result = dry_run_engine.evaluate(
operation=action.operation,
parameters=action.parameters,
user_role="cluster-admin", # TODO: 從 JWT 取得真實角色
)
# 轉換為 API Response 格式
return DryRunResponse(
checks=[
DryRunCheckResponse(
name=c.name,
passed=c.passed,
message=c.message,
)
for c in result.checks
],
blast_radius=BlastRadiusResponse(
affected_pods=result.blast_radius.affected_pods,
estimated_downtime=result.blast_radius.estimated_downtime,
related_services=result.blast_radius.related_services,
data_impact=result.blast_radius.data_impact,
),
overall_passed=result.overall_passed,
risk_level=result.risk_level,
)
@router.post("/dry-run/preview", response_model=DryRunResponse)
async def preview_dry_run(
operation: str,
parameters: dict,
user_role: str = "cluster-admin",
) -> DryRunResponse:
"""
預覽 Dry-Run (不需要先建立 Approval)
用於前端即時預覽操作風險
"""
result = dry_run_engine.evaluate(
operation=operation,
parameters=parameters,
user_role=user_role,
)
return DryRunResponse(
checks=[
DryRunCheckResponse(
name=c.name,
passed=c.passed,
message=c.message,
)
for c in result.checks
],
blast_radius=BlastRadiusResponse(
affected_pods=result.blast_radius.affected_pods,
estimated_downtime=result.blast_radius.estimated_downtime,
related_services=result.blast_radius.related_services,
data_impact=result.blast_radius.data_impact,
),
overall_passed=result.overall_passed,
risk_level=result.risk_level,
)
# ==================== Test Helpers ====================
def create_test_approval(
operation: str = "delete_pod",
parameters: dict | None = None,
risk_level: Literal["low", "medium", "high", "critical"] = "high",
) -> Approval:
"""Create a test approval for development"""
approval_id = uuid4()
now = datetime.utcnow()
if parameters is None:
if operation == "delete_pod":
parameters = {"pod_name": "nginx-frontend-7d4b8c9f5-xk2m3"}
elif operation == "drop_table":
parameters = {"table_name": "user_sessions"}
else:
parameters = {}
approval = Approval(
id=approval_id,
type="action_execution",
status="pending",
action=PendingAction(
plugin_id="lewooogo-action-k8s",
operation=operation,
parameters=parameters,
risk_level=risk_level,
),
requested_at=now,
expires_at=now + timedelta(hours=1),
)
_approvals[approval_id] = approval
return approval
def create_test_approvals() -> list[Approval]:
"""建立多個測試 Approval (對應前端 Mock Data)"""
return [
# HIGH RISK: 刪除 Pod
create_test_approval(
operation="delete_pod",
parameters={"pod_name": "nginx-frontend-7d4b8c9f5-xk2m3"},
risk_level="high",
),
# CRITICAL: DROP TABLE (DESTRUCTIVE)
create_test_approval(
operation="drop_table",
parameters={"table_name": "user_sessions"},
risk_level="critical",
),
# MEDIUM: Scale Deployment
create_test_approval(
operation="scale_deployment",
parameters={"deployment": "api-server", "replicas": 5},
risk_level="medium",
),
]

View File

@@ -0,0 +1,107 @@
"""
Health Check Endpoints
======================
K8s probes + component health checks
Endpoints:
- GET /health - Full health check with components
- GET /health/ready - K8s readinessProbe
- GET /health/live - K8s livenessProbe
"""
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter
from pydantic import BaseModel
from src.core.config import settings
from src.core.logging import get_logger
router = APIRouter()
logger = get_logger("awoooi.health")
class ComponentStatus(BaseModel):
"""Individual component status"""
name: str
status: Literal["up", "down", "degraded"]
latency_ms: float | None = None
message: str | None = None
class HealthResponse(BaseModel):
"""Full health check response"""
status: Literal["healthy", "degraded", "unhealthy"]
version: str
environment: str
timestamp: datetime
components: dict[str, Literal["up", "down", "degraded"]]
@router.get("/health", response_model=HealthResponse)
async def get_health() -> HealthResponse:
"""
Full health check with component status
Returns overall system health and individual component statuses.
Used for monitoring dashboards and alerting.
"""
# TODO: Implement actual async health checks
components = {
"api": "up",
"database": "up", # TODO: asyncpg ping
"redis": "up", # TODO: redis ping
"ollama": "up", # TODO: httpx check
"clawbot": "up", # TODO: httpx check
}
# Determine overall status
down_count = sum(1 for s in components.values() if s == "down")
degraded_count = sum(1 for s in components.values() if s == "degraded")
if down_count > 0:
overall_status: Literal["healthy", "degraded", "unhealthy"] = "unhealthy"
elif degraded_count > 0:
overall_status = "degraded"
else:
overall_status = "healthy"
logger.debug(
"health_check",
status=overall_status,
components=components,
)
return HealthResponse(
status=overall_status,
version=settings.VERSION,
environment=settings.ENVIRONMENT,
timestamp=datetime.now(timezone.utc),
components=components,
)
@router.get("/health/ready")
async def get_readiness() -> dict[str, str]:
"""
K8s readinessProbe
Returns 200 when the service is ready to accept traffic.
Used by K8s to determine if pod should receive traffic.
"""
# TODO: Check if all required connections are established
logger.debug("readiness_check", ready=True)
return {"status": "ready"}
@router.get("/health/live")
async def get_liveness() -> dict[str, str]:
"""
K8s livenessProbe
Returns 200 when the service is alive.
Used by K8s to determine if pod needs restart.
"""
logger.debug("liveness_check", alive=True)
return {"status": "alive"}

View File

@@ -0,0 +1,73 @@
"""
Notification Endpoints
"""
from datetime import datetime
from typing import Literal
from uuid import UUID, uuid4
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class NotificationChannel(BaseModel):
id: str
type: Literal["telegram", "slack", "line", "email", "discord", "webhook"]
name: str
enabled: bool
class NotificationRequest(BaseModel):
channel_id: str
message: str
template_id: str | None = None
variables: dict | None = None
priority: Literal["low", "normal", "high", "urgent"] = "normal"
class NotificationResult(BaseModel):
id: UUID
status: Literal["queued", "sent", "failed"]
sent_at: datetime | None = None
error: str | None = None
# Mock channels
MOCK_CHANNELS: list[NotificationChannel] = [
NotificationChannel(
id="telegram-ops",
type="telegram",
name="Ops Team",
enabled=True,
),
NotificationChannel(
id="slack-alerts",
type="slack",
name="Alerts Channel",
enabled=True,
),
NotificationChannel(
id="email-oncall",
type="email",
name="On-Call Email",
enabled=True,
),
]
@router.get("/channels", response_model=list[NotificationChannel])
async def list_notification_channels() -> list[NotificationChannel]:
"""列出通知頻道"""
return MOCK_CHANNELS
@router.post("/send", response_model=NotificationResult, status_code=202)
async def send_notification(request: NotificationRequest) -> NotificationResult:
"""發送通知"""
# TODO: 實際發送通知
return NotificationResult(
id=uuid4(),
status="queued",
)

View File

@@ -0,0 +1,110 @@
"""
Pipeline Endpoints
"""
from datetime import datetime
from typing import Literal
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter()
class PipelineStep(BaseModel):
id: str
plugin_id: str
type: Literal["INPUT", "BRAIN", "OUTPUT", "ACTION", "DATA", "UI"]
config: dict | None = None
class Pipeline(BaseModel):
id: UUID
name: str
description: str | None = None
status: Literal["draft", "active", "paused", "archived"]
steps: list[PipelineStep]
created_at: datetime
updated_at: datetime
class PipelineCreate(BaseModel):
name: str
description: str | None = None
steps: list[PipelineStep]
class PipelineExecution(BaseModel):
id: UUID
pipeline_id: UUID
status: Literal["pending", "running", "completed", "failed", "cancelled"]
started_at: datetime
completed_at: datetime | None = None
class PipelineList(BaseModel):
items: list[Pipeline]
next_page_token: str | None = None
# In-memory storage
_pipelines: dict[UUID, Pipeline] = {}
@router.get("", response_model=PipelineList)
async def list_pipelines(
status: Literal["draft", "active", "paused", "archived"] | None = None,
) -> PipelineList:
"""列出工作流"""
items = list(_pipelines.values())
if status:
items = [p for p in items if p.status == status]
return PipelineList(items=items)
@router.post("", response_model=Pipeline, status_code=201)
async def create_pipeline(data: PipelineCreate) -> Pipeline:
"""建立工作流"""
now = datetime.utcnow()
pipeline = Pipeline(
id=uuid4(),
name=data.name,
description=data.description,
status="draft",
steps=data.steps,
created_at=now,
updated_at=now,
)
_pipelines[pipeline.id] = pipeline
return pipeline
@router.get("/{pipeline_id}", response_model=Pipeline)
async def get_pipeline(pipeline_id: UUID) -> Pipeline:
"""取得工作流詳情"""
if pipeline_id not in _pipelines:
raise HTTPException(status_code=404, detail="Pipeline not found")
return _pipelines[pipeline_id]
@router.delete("/{pipeline_id}", status_code=204)
async def delete_pipeline(pipeline_id: UUID) -> None:
"""刪除工作流"""
if pipeline_id not in _pipelines:
raise HTTPException(status_code=404, detail="Pipeline not found")
del _pipelines[pipeline_id]
@router.post("/{pipeline_id}/trigger", response_model=PipelineExecution, status_code=202)
async def trigger_pipeline(pipeline_id: UUID) -> PipelineExecution:
"""手動觸發工作流"""
if pipeline_id not in _pipelines:
raise HTTPException(status_code=404, detail="Pipeline not found")
return PipelineExecution(
id=uuid4(),
pipeline_id=pipeline_id,
status="pending",
started_at=datetime.utcnow(),
)

View File

@@ -0,0 +1,98 @@
"""
Plugin Management Endpoints
"""
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter()
PluginCategory = Literal["INPUT", "BRAIN", "OUTPUT", "ACTION", "DATA", "UI"]
class Plugin(BaseModel):
id: str
name: str
version: str
category: PluginCategory
enabled: bool
description: str | None = None
class PluginHealth(BaseModel):
plugin_id: str
status: Literal["healthy", "unhealthy", "unknown"]
last_check: datetime
error: str | None = None
# Mock data
MOCK_PLUGINS: list[Plugin] = [
Plugin(
id="lewooogo-input-webhook",
name="Webhook Trigger",
version="0.1.0",
category="INPUT",
enabled=True,
description="HTTP Webhook 觸發器",
),
Plugin(
id="lewooogo-brain-llm-router",
name="LLM Router",
version="0.1.0",
category="BRAIN",
enabled=True,
description="多模型路由器",
),
Plugin(
id="lewooogo-output-telegram",
name="Telegram Notifier",
version="0.1.0",
category="OUTPUT",
enabled=True,
description="Telegram 通知",
),
]
@router.get("", response_model=list[Plugin])
async def list_plugins(
category: PluginCategory | None = None,
enabled: bool | None = None,
) -> list[Plugin]:
"""列出所有已註冊 Plugin"""
result = MOCK_PLUGINS
if category:
result = [p for p in result if p.category == category]
if enabled is not None:
result = [p for p in result if p.enabled == enabled]
return result
@router.get("/{plugin_id}", response_model=Plugin)
async def get_plugin(plugin_id: str) -> Plugin:
"""取得 Plugin 詳情"""
for plugin in MOCK_PLUGINS:
if plugin.id == plugin_id:
return plugin
raise HTTPException(status_code=404, detail="Plugin not found")
@router.get("/{plugin_id}/health", response_model=PluginHealth)
async def get_plugin_health(plugin_id: str) -> PluginHealth:
"""Plugin 健康檢查"""
# Check if plugin exists
found = any(p.id == plugin_id for p in MOCK_PLUGINS)
if not found:
raise HTTPException(status_code=404, detail="Plugin not found")
return PluginHealth(
plugin_id=plugin_id,
status="healthy",
last_check=datetime.utcnow(),
)