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,24 @@
"""
leWOOOgo Notification System
=============================
Phase 6: Output Plugins 生態系
NotificationProvider 介面 + 具體實作:
- DiscordWebhookProvider
- SlackWebhookProvider (TODO)
- LineNotifyProvider (TODO)
"""
from .base import NotificationProvider, NotificationMessage, NotificationResult, ExecutionStatus
from .discord import DiscordWebhookProvider
from .manager import NotificationManager, get_notification_manager
__all__ = [
"NotificationProvider",
"NotificationMessage",
"NotificationResult",
"ExecutionStatus",
"DiscordWebhookProvider",
"NotificationManager",
"get_notification_manager",
]

View File

@@ -0,0 +1,163 @@
"""
Notification Provider Base Interface
=====================================
Phase 6: leWOOOgo Output Plugins
設計原則:
1. 抽象介面 - 所有 Provider 必須實作 send()
2. 統一訊息格式 - NotificationMessage
3. 結果追蹤 - NotificationResult
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Any
class NotificationStatus(str, Enum):
"""通知狀態"""
SUCCESS = "success"
FAILED = "failed"
SKIPPED = "skipped"
class ExecutionStatus(str, Enum):
"""執行狀態"""
SUCCESS = "success"
FAILED = "failed"
DRY_RUN_BLOCKED = "dry_run_blocked"
PENDING = "pending"
@dataclass
class NotificationMessage:
"""
通知訊息統一格式
所有 Provider 都從這個格式轉換成各自的 API 格式
"""
# 執行結果
execution_status: ExecutionStatus
# 核心資訊
action_title: str
action_description: str
approval_id: str
# 簽核資訊
signers: list[dict[str, str]] = field(default_factory=list) # [{"name": "CTO", "comment": "..."}]
required_signatures: int = 1
# 影響範圍 (Blast Radius)
affected_pods: int = 0
estimated_downtime: str = "N/A"
related_services: list[str] = field(default_factory=list)
data_impact: str = "none"
# 執行細節
namespace: str = "default"
operation_type: str = "unknown"
duration_ms: int | None = None
error_message: str | None = None
# AI 分析
risk_level: str = "medium"
ai_provider: str = "unknown"
confidence: float | None = None
# 時間戳
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@property
def status_emoji(self) -> str:
"""狀態 Emoji"""
if self.execution_status == ExecutionStatus.SUCCESS:
return ""
elif self.execution_status == ExecutionStatus.FAILED:
return ""
elif self.execution_status == ExecutionStatus.DRY_RUN_BLOCKED:
return "🛡️"
return ""
@property
def status_text(self) -> str:
"""狀態文字"""
if self.execution_status == ExecutionStatus.SUCCESS:
return "任務執行成功"
elif self.execution_status == ExecutionStatus.FAILED:
return "執行失敗"
elif self.execution_status == ExecutionStatus.DRY_RUN_BLOCKED:
return "Dry-Run 攔截"
return "等待中"
@property
def risk_emoji(self) -> str:
"""風險等級 Emoji"""
if self.risk_level == "critical":
return "🔴"
elif self.risk_level == "medium":
return "🟡"
return "🟢"
@property
def signers_display(self) -> str:
"""簽核者顯示文字"""
if not self.signers:
return ""
return ", ".join([s.get("name", "Unknown") for s in self.signers])
@dataclass
class NotificationResult:
"""通知發送結果"""
status: NotificationStatus
provider: str
message: str
response_data: dict[str, Any] | None = None
error: str | None = None
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
class NotificationProvider(ABC):
"""
通知提供者抽象介面
所有 Output Plugin 必須實作此介面
"""
@property
@abstractmethod
def name(self) -> str:
"""Provider 名稱"""
pass
@property
@abstractmethod
def enabled(self) -> bool:
"""是否啟用"""
pass
@abstractmethod
async def send(self, message: NotificationMessage) -> NotificationResult:
"""
發送通知
Args:
message: 統一格式的通知訊息
Returns:
NotificationResult: 發送結果
"""
pass
@abstractmethod
async def test_connection(self) -> bool:
"""
測試連線
Returns:
bool: 是否連線成功
"""
pass

View File

@@ -0,0 +1,274 @@
"""
Discord Webhook Provider
========================
Phase 6: leWOOOgo Output Plugins
精美戰報格式:
- Discord Embed 豐富內容
- 狀態顏色標示
- 簽核者、影響範圍完整呈現
"""
import httpx
from datetime import datetime, timezone
from src.core.config import settings
from src.core.logging import get_logger
from .base import (
NotificationProvider,
NotificationMessage,
NotificationResult,
NotificationStatus,
ExecutionStatus,
)
logger = get_logger("awoooi.notifications.discord")
class DiscordWebhookProvider(NotificationProvider):
"""
Discord Webhook 通知提供者
使用 Discord Embed 格式發送精美戰報
"""
def __init__(self, webhook_url: str | None = None):
self._webhook_url = webhook_url or settings.DISCORD_WEBHOOK_URL
self._client: httpx.AsyncClient | None = None
@property
def name(self) -> str:
return "discord"
@property
def enabled(self) -> bool:
return bool(self._webhook_url)
async def _get_client(self) -> httpx.AsyncClient:
"""取得 HTTP Client (timeout=5s 防止主執行緒阻塞)"""
if self._client is None:
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(5.0, connect=3.0), # 總超時 5s, 連線 3s
)
return self._client
def _get_embed_color(self, status: ExecutionStatus) -> int:
"""取得 Embed 顏色 (Discord 使用十進位整數)"""
if status == ExecutionStatus.SUCCESS:
return 0x00FF00 # 綠色
elif status == ExecutionStatus.FAILED:
return 0xFF0000 # 紅色
elif status == ExecutionStatus.DRY_RUN_BLOCKED:
return 0xFFA500 # 橙色
return 0x808080 # 灰色
def _build_embed(self, message: NotificationMessage) -> dict:
"""
建構 Discord Embed 精美戰報
格式:
┌────────────────────────────────────────┐
│ ✅ 任務執行成功 │
│ ───────────────────────────────────── │
│ 🎯 動作: 重新啟動 harbor-core │
│ 📋 描述: Pod CrashLoopBackOff 修復 │
│ ───────────────────────────────────── │
│ 👥 簽核者: CTO 林技術長, CISO 陳資安長 │
│ 🔴 風險等級: CRITICAL │
│ ───────────────────────────────────── │
│ 💥 影響範圍 │
│ • 受影響 Pods: 3 │
│ • 預估停機: ~30s │
│ • 相關服務: api, auth │
│ ───────────────────────────────────── │
│ 🤖 AI Provider: Ollama (信心度: 85%) │
│ ⏱️ 執行時間: 234ms │
└────────────────────────────────────────┘
"""
# 標題
title = f"{message.status_emoji} {message.status_text}"
# 描述
description = f"**{message.action_title}**"
if message.action_description:
description += f"\n{message.action_description[:200]}"
# 簽核者欄位
signers_value = message.signers_display
if message.signers:
signers_details = []
for s in message.signers:
detail = f"{s.get('name', 'Unknown')}"
if s.get("comment"):
detail += f" - _{s['comment'][:50]}_"
signers_details.append(detail)
signers_value = "\n".join(signers_details)
# 影響範圍欄位
blast_radius_lines = [
f"• 受影響 Pods: **{message.affected_pods}**",
f"• 預估停機: **{message.estimated_downtime}**",
f"• 資料影響: **{message.data_impact.upper()}**",
]
if message.related_services:
services = ", ".join(message.related_services[:5])
blast_radius_lines.append(f"• 相關服務: {services}")
# 執行細節
execution_lines = [
f"• 操作類型: **{message.operation_type}**",
f"• Namespace: `{message.namespace}`",
]
if message.duration_ms:
execution_lines.append(f"• 執行時間: **{message.duration_ms}ms**")
if message.error_message:
execution_lines.append(f"• 錯誤: `{message.error_message[:100]}`")
# AI 資訊
ai_lines = [f"• Provider: **{message.ai_provider}**"]
if message.confidence:
ai_lines.append(f"• 信心度: **{message.confidence:.0%}**")
# 建構 Embed
embed = {
"title": title,
"description": description,
"color": self._get_embed_color(message.execution_status),
"fields": [
{
"name": f"👥 簽核者 ({len(message.signers)}/{message.required_signatures})",
"value": signers_value or "",
"inline": True,
},
{
"name": f"{message.risk_emoji} 風險等級",
"value": message.risk_level.upper(),
"inline": True,
},
{
"name": "💥 影響範圍 (Blast Radius)",
"value": "\n".join(blast_radius_lines),
"inline": False,
},
{
"name": "⚙️ 執行細節",
"value": "\n".join(execution_lines),
"inline": True,
},
{
"name": "🤖 AI 分析",
"value": "\n".join(ai_lines),
"inline": True,
},
],
"footer": {
"text": f"AWOOOI leWOOOgo Engine | Approval ID: {message.approval_id[:8]}...",
"icon_url": "https://cdn.discordapp.com/emojis/1234567890.png", # 可替換
},
"timestamp": message.timestamp.isoformat(),
}
return embed
async def send(self, message: NotificationMessage) -> NotificationResult:
"""發送 Discord 精美戰報"""
if not self.enabled:
logger.warning("discord_webhook_disabled", reason="No webhook URL configured")
return NotificationResult(
status=NotificationStatus.SKIPPED,
provider=self.name,
message="Discord webhook not configured",
)
try:
client = await self._get_client()
# 建構 Discord Webhook Payload
payload = {
"username": "AWOOOI ClawBot",
"avatar_url": "https://i.imgur.com/your-avatar.png", # 可替換
"embeds": [self._build_embed(message)],
}
logger.info(
"discord_sending_notification",
approval_id=message.approval_id,
status=message.execution_status.value,
)
# 發送請求
response = await client.post(
self._webhook_url,
json=payload,
)
if response.status_code in (200, 204):
logger.info(
"discord_notification_sent",
approval_id=message.approval_id,
status_code=response.status_code,
)
return NotificationResult(
status=NotificationStatus.SUCCESS,
provider=self.name,
message="Discord notification sent successfully",
response_data={"status_code": response.status_code},
)
else:
error_text = response.text[:200]
logger.error(
"discord_notification_failed",
approval_id=message.approval_id,
status_code=response.status_code,
error=error_text,
)
return NotificationResult(
status=NotificationStatus.FAILED,
provider=self.name,
message=f"Discord API error: {response.status_code}",
error=error_text,
)
except Exception as e:
logger.exception(
"discord_notification_exception",
approval_id=message.approval_id,
error=str(e),
)
return NotificationResult(
status=NotificationStatus.FAILED,
provider=self.name,
message="Exception occurred",
error=str(e),
)
async def test_connection(self) -> bool:
"""測試 Discord Webhook 連線"""
if not self.enabled:
return False
try:
client = await self._get_client()
# 發送測試訊息
test_payload = {
"username": "AWOOOI ClawBot",
"content": "🔔 **AWOOOI 連線測試** - leWOOOgo Notification System 已就緒!",
}
response = await client.post(
self._webhook_url,
json=test_payload,
)
return response.status_code in (200, 204)
except Exception as e:
logger.error("discord_connection_test_failed", error=str(e))
return False
async def close(self) -> None:
"""關閉 HTTP client"""
if self._client:
await self._client.aclose()
self._client = None

View File

@@ -0,0 +1,169 @@
"""
Notification Manager
====================
Phase 6: leWOOOgo Output Plugins
管理所有 NotificationProvider統一發送介面
"""
from src.core.logging import get_logger
from .base import (
NotificationProvider,
NotificationMessage,
NotificationResult,
NotificationStatus,
)
from .discord import DiscordWebhookProvider
logger = get_logger("awoooi.notifications.manager")
class NotificationManager:
"""
通知管理器
管理多個 NotificationProvider支援:
- 同時發送至多個頻道
- 優雅降級 (單一 Provider 失敗不影響其他)
- 結果追蹤
"""
def __init__(self):
self._providers: list[NotificationProvider] = []
self._initialized = False
def register(self, provider: NotificationProvider) -> None:
"""註冊 Provider"""
if provider.enabled:
self._providers.append(provider)
logger.info(
"notification_provider_registered",
provider=provider.name,
enabled=provider.enabled,
)
else:
logger.warning(
"notification_provider_disabled",
provider=provider.name,
)
def initialize(self) -> None:
"""初始化所有 Provider"""
if self._initialized:
return
# 註冊 Discord
discord = DiscordWebhookProvider()
self.register(discord)
# TODO: 註冊其他 Provider
# slack = SlackWebhookProvider()
# self.register(slack)
self._initialized = True
logger.info(
"notification_manager_initialized",
provider_count=len(self._providers),
providers=[p.name for p in self._providers],
)
async def send_all(self, message: NotificationMessage) -> list[NotificationResult]:
"""
發送通知至所有已註冊的 Provider
Returns:
list[NotificationResult]: 各 Provider 的發送結果
"""
if not self._initialized:
self.initialize()
if not self._providers:
logger.warning("no_notification_providers_available")
return [
NotificationResult(
status=NotificationStatus.SKIPPED,
provider="none",
message="No notification providers configured",
)
]
results = []
for provider in self._providers:
try:
result = await provider.send(message)
results.append(result)
logger.info(
"notification_sent",
provider=provider.name,
status=result.status.value,
)
except Exception as e:
logger.exception(
"notification_send_failed",
provider=provider.name,
error=str(e),
)
results.append(
NotificationResult(
status=NotificationStatus.FAILED,
provider=provider.name,
message="Exception during send",
error=str(e),
)
)
return results
async def test_all(self) -> dict[str, bool]:
"""
測試所有 Provider 連線
Returns:
dict[str, bool]: Provider 名稱 → 連線狀態
"""
if not self._initialized:
self.initialize()
results = {}
for provider in self._providers:
try:
results[provider.name] = await provider.test_connection()
except Exception as e:
logger.error(
"notification_test_failed",
provider=provider.name,
error=str(e),
)
results[provider.name] = False
return results
async def close(self) -> None:
"""關閉所有 Provider"""
for provider in self._providers:
if hasattr(provider, "close"):
await provider.close()
# =============================================================================
# Singleton Instance
# =============================================================================
_notification_manager: NotificationManager | None = None
def get_notification_manager() -> NotificationManager:
"""取得 NotificationManager 單例"""
global _notification_manager
if _notification_manager is None:
_notification_manager = NotificationManager()
_notification_manager.initialize()
return _notification_manager
async def close_notification_manager() -> None:
"""關閉 NotificationManager"""
global _notification_manager
if _notification_manager:
await _notification_manager.close()
_notification_manager = None