feat(awooop): Operator Console API + 前端(leWOOOgo 積木化修復)
All checks were successful
Code Review / ai-code-review (push) Successful in 42s
All checks were successful
Code Review / ai-code-review (push) Successful in 42s
後端: - 新增 platform_operator_service.py(DB 存取集中 Service 層) - Router 層移除 Depends(get_db),改呼叫 Service 函數 - tenants/contracts/operator_runs 三個 Router 符合 leWOOOgo 規範 - __init__.py 整合四個 platform router 前端: - apps/web/src/app/[locale]/awooop/ 完整建立(7 個頁面) - layout.tsx:四分頁導覽(tenants/contracts/runs/approvals) - 全部使用 @/i18n/routing(Link/usePathname/useRouter)避免 i18n 路徑問題 - approvals page:10s 自動刷新、timeout 倒數、緊急紅色高亮 ADR-106/107/112/114/115/116 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,22 @@
|
||||
"""AwoooP Platform API — Phase 4 Shadow Mode Shell"""
|
||||
from src.api.v1.platform.runs import router
|
||||
"""
|
||||
AwoooP Platform API — Operator Console Router 彙整
|
||||
===================================================
|
||||
Phase 4 Shadow Mode + Phase 8 Operator Console
|
||||
ADR-106/ADR-107/ADR-114/ADR-115/ADR-116
|
||||
2026-05-05 ogt + Claude Sonnet 4.6(新增 Operator Console 四 router)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.v1.platform.contracts import router as contracts_router
|
||||
from src.api.v1.platform.operator_runs import router as operator_runs_router
|
||||
from src.api.v1.platform.runs import router as runs_router
|
||||
from src.api.v1.platform.tenants import router as tenants_router
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(runs_router)
|
||||
router.include_router(tenants_router)
|
||||
router.include_router(contracts_router)
|
||||
router.include_router(operator_runs_router)
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
53
apps/api/src/api/v1/platform/contracts.py
Normal file
53
apps/api/src/api/v1/platform/contracts.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
AwoooP Operator Console — Contracts List API
|
||||
=============================================
|
||||
ADR-106(AwoooP Agent Platform),ADR-107/ADR-112(Contract Revision)
|
||||
2026-05-05 ogt + Claude Sonnet 4.6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.services.platform_operator_service import list_contracts as list_contracts_svc
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ContractItem(BaseModel):
|
||||
revision_id: UUID
|
||||
contract_id: str
|
||||
contract_family: str
|
||||
lifecycle_status: str
|
||||
body_hash: str
|
||||
version_major: int
|
||||
version_minor: int
|
||||
created_at: datetime
|
||||
project_id: str
|
||||
|
||||
|
||||
class ListContractsResponse(BaseModel):
|
||||
contracts: list[ContractItem]
|
||||
total: int
|
||||
|
||||
|
||||
@router.get(
|
||||
"/contracts",
|
||||
response_model=ListContractsResponse,
|
||||
summary="列出合約 Revisions",
|
||||
description=(
|
||||
"返回 awooop_contract_revisions,支援 project_id / lifecycle_status filter。\n\n"
|
||||
"- 按 created_at DESC 排序,最多 200 筆\n"
|
||||
"- ADR-107/ADR-112:append-only revision 表,只查不寫"
|
||||
),
|
||||
)
|
||||
async def list_contracts(
|
||||
project_id: str | None = Query(None, description="租戶 ID(可選)"),
|
||||
lifecycle_status: str | None = Query(None, description="lifecycle status filter(draft/published/active/revoked)"),
|
||||
) -> dict[str, Any]:
|
||||
return await list_contracts_svc(project_id=project_id, lifecycle_status=lifecycle_status)
|
||||
136
apps/api/src/api/v1/platform/operator_runs.py
Normal file
136
apps/api/src/api/v1/platform/operator_runs.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
AwoooP Operator Console — Runs List & Approval API
|
||||
====================================================
|
||||
GET /runs/list — 列出 runs(可 filter)
|
||||
GET /approvals — 列出待審核 runs(state=waiting_approval)
|
||||
POST /approvals/{run_id}/decide — 核准或拒絕 run
|
||||
ADR-106(AwoooP Agent Platform),ADR-114(Run State Machine),ADR-116(Gate 5 Approval)
|
||||
2026-05-05 ogt + Claude Sonnet 4.6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.services.platform_operator_service import (
|
||||
decide_approval as decide_approval_svc,
|
||||
list_approvals as list_approvals_svc,
|
||||
list_runs as list_runs_svc,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_DEFAULT_PER_PAGE = 50
|
||||
_MAX_PER_PAGE = 200
|
||||
|
||||
|
||||
class RunItem(BaseModel):
|
||||
run_id: UUID
|
||||
project_id: str
|
||||
agent_id: str
|
||||
state: str
|
||||
is_shadow: bool
|
||||
cost_usd: Decimal
|
||||
step_count: int
|
||||
created_at: datetime
|
||||
timeout_at: datetime | None
|
||||
|
||||
|
||||
class ListRunsResponse(BaseModel):
|
||||
runs: list[RunItem]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
|
||||
|
||||
class ApprovalItem(BaseModel):
|
||||
run_id: UUID
|
||||
project_id: str
|
||||
agent_id: str
|
||||
created_at: datetime
|
||||
timeout_at: datetime | None
|
||||
|
||||
|
||||
class ListApprovalsResponse(BaseModel):
|
||||
items: list[ApprovalItem]
|
||||
total: int
|
||||
|
||||
|
||||
class DecideApprovalRequest(BaseModel):
|
||||
project_id: str = Field(..., description="租戶 ID")
|
||||
decision: Literal["approve", "reject"] = Field(..., description="核准或拒絕")
|
||||
approver_id: str = Field(..., description="審核人 ID(platform_subject_id 或 operator email)")
|
||||
reason: str | None = Field(None, description="決策原因(可選)")
|
||||
|
||||
|
||||
class DecideApprovalResponse(BaseModel):
|
||||
run_id: str
|
||||
decision: str
|
||||
new_state: str
|
||||
approval_token_jti: str | None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runs/list",
|
||||
response_model=ListRunsResponse,
|
||||
summary="列出 Runs",
|
||||
description=(
|
||||
"返回 awooop_run_state 記錄,支援 project_id / state filter 與分頁。\n\n"
|
||||
"- 按 created_at DESC 排序\n"
|
||||
"- 注意:此路徑為 /runs/list 以避免與 runs.py 的 /runs/{run_id} 衝突"
|
||||
),
|
||||
)
|
||||
async def list_runs(
|
||||
project_id: str | None = Query(None, description="租戶 ID(可選)"),
|
||||
state: str | None = Query(None, description="Run 狀態 filter(可選)"),
|
||||
page: int = Query(1, ge=1, description="頁碼,從 1 開始"),
|
||||
per_page: int = Query(_DEFAULT_PER_PAGE, ge=1, le=_MAX_PER_PAGE, description="每頁筆數"),
|
||||
) -> dict[str, Any]:
|
||||
return await list_runs_svc(
|
||||
project_id=project_id, state=state, page=page, per_page=per_page
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/approvals",
|
||||
response_model=ListApprovalsResponse,
|
||||
summary="列出待審核 Runs",
|
||||
description=(
|
||||
"返回 state=waiting_approval 的 runs,即需要人工審核的任務清單。\n\n"
|
||||
"ADR-116 Gate 5:人工審核關卡"
|
||||
),
|
||||
)
|
||||
async def list_approvals(
|
||||
project_id: str | None = Query(None, description="租戶 ID(可選)"),
|
||||
) -> dict[str, Any]:
|
||||
return await list_approvals_svc(project_id=project_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/approvals/{run_id}/decide",
|
||||
response_model=DecideApprovalResponse,
|
||||
summary="核准或拒絕 Run",
|
||||
description=(
|
||||
"對 waiting_approval 狀態的 run 做出審核決定。\n\n"
|
||||
"- approve:發行 approval token → record_approval → run 轉為 running\n"
|
||||
"- reject:直接 transition → cancelled\n\n"
|
||||
"ADR-116 Gate 5:Operator Console 人工審核"
|
||||
),
|
||||
)
|
||||
async def decide_approval(
|
||||
run_id: str,
|
||||
body: DecideApprovalRequest,
|
||||
) -> dict[str, Any]:
|
||||
return await decide_approval_svc(
|
||||
run_id=run_id,
|
||||
project_id=body.project_id,
|
||||
decision=body.decision,
|
||||
approver_id=body.approver_id,
|
||||
reason=body.reason,
|
||||
)
|
||||
47
apps/api/src/api/v1/platform/tenants.py
Normal file
47
apps/api/src/api/v1/platform/tenants.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
AwoooP Operator Console — Tenants List API
|
||||
==========================================
|
||||
ADR-106(AwoooP Agent Platform),ADR-115(Tenant Onboarding)
|
||||
2026-05-05 ogt + Claude Sonnet 4.6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from src.services.platform_operator_service import list_tenants as list_tenants_svc
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TenantItem(BaseModel):
|
||||
project_id: str
|
||||
display_name: str
|
||||
migration_mode: str
|
||||
budget_limit_usd: Decimal | None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ListTenantsResponse(BaseModel):
|
||||
tenants: list[TenantItem]
|
||||
total: int
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tenants",
|
||||
response_model=ListTenantsResponse,
|
||||
summary="列出所有租戶",
|
||||
description=(
|
||||
"返回所有 awooop_projects 記錄(含已停用)。\n\n"
|
||||
"ADR-106/ADR-115:Operator Console 使用,不依 RLS 過濾。"
|
||||
),
|
||||
)
|
||||
async def list_tenants() -> dict[str, Any]:
|
||||
return await list_tenants_svc()
|
||||
293
apps/api/src/services/platform_operator_service.py
Normal file
293
apps/api/src/services/platform_operator_service.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
AwoooP Operator Console — Platform Operator Service
|
||||
====================================================
|
||||
leWOOOgo 積木化:DB 存取集中在 Service 層,Router 不直接引用 get_db。
|
||||
ADR-106(AwoooP Agent Platform)
|
||||
2026-05-05 ogt + Claude Sonnet 4.6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import structlog
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from src.db.awooop_models import (
|
||||
AwoooPContractRevision,
|
||||
AwoooPProject,
|
||||
AwoooPRunState,
|
||||
)
|
||||
from src.db.base import get_db_context
|
||||
from src.services.audit_sink import write_audit
|
||||
from src.services.awooop_approval_token import issue_approval_token, record_approval
|
||||
from src.services.run_state_machine import transition
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
_MAX_CONTRACTS = 200
|
||||
_DEFAULT_PER_PAGE = 50
|
||||
_MAX_PER_PAGE = 200
|
||||
|
||||
# =============================================================================
|
||||
# Tenants
|
||||
# =============================================================================
|
||||
|
||||
async def list_tenants() -> dict[str, Any]:
|
||||
"""列出所有 AwoooP 租戶(Operator Console,不依 RLS 過濾)。"""
|
||||
async with get_db_context("awoooi") as db:
|
||||
result = await db.execute(
|
||||
select(AwoooPProject).order_by(AwoooPProject.created_at.asc())
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
tenants = [
|
||||
{
|
||||
"project_id": r.project_id,
|
||||
"display_name": r.display_name,
|
||||
"migration_mode": r.migration_mode,
|
||||
"budget_limit_usd": r.budget_limit_usd,
|
||||
"is_active": r.is_active,
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"tenants": tenants, "total": len(tenants)}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Contracts
|
||||
# =============================================================================
|
||||
|
||||
async def list_contracts(
|
||||
project_id: str | None,
|
||||
lifecycle_status: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""列出合約 revisions(可 filter by project_id / lifecycle_status)。"""
|
||||
async with get_db_context("awoooi") as db:
|
||||
stmt = select(AwoooPContractRevision).order_by(
|
||||
AwoooPContractRevision.created_at.desc()
|
||||
)
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(AwoooPContractRevision.project_id == project_id)
|
||||
if lifecycle_status is not None:
|
||||
stmt = stmt.where(
|
||||
AwoooPContractRevision.lifecycle_status == lifecycle_status
|
||||
)
|
||||
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
stmt = stmt.limit(_MAX_CONTRACTS)
|
||||
result = await db.execute(stmt)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
contracts = [
|
||||
{
|
||||
"revision_id": r.revision_id,
|
||||
"contract_id": r.contract_id,
|
||||
"contract_family": r.contract_family,
|
||||
"lifecycle_status": r.lifecycle_status,
|
||||
"body_hash": r.body_hash,
|
||||
"version_major": r.version_major,
|
||||
"version_minor": r.version_minor,
|
||||
"created_at": r.created_at,
|
||||
"project_id": r.project_id,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"contracts": contracts, "total": total}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Runs
|
||||
# =============================================================================
|
||||
|
||||
async def list_runs(
|
||||
project_id: str | None,
|
||||
state: str | None,
|
||||
page: int,
|
||||
per_page: int,
|
||||
) -> dict[str, Any]:
|
||||
"""列出 runs,支援 project_id、state filter 與分頁。"""
|
||||
async with get_db_context("awoooi") as db:
|
||||
stmt = select(AwoooPRunState).order_by(AwoooPRunState.created_at.desc())
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(AwoooPRunState.project_id == project_id)
|
||||
if state is not None:
|
||||
stmt = stmt.where(AwoooPRunState.state == state)
|
||||
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
stmt = stmt.offset(offset).limit(per_page)
|
||||
result = await db.execute(stmt)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
runs = [
|
||||
{
|
||||
"run_id": r.run_id,
|
||||
"project_id": r.project_id,
|
||||
"agent_id": r.agent_id,
|
||||
"state": r.state,
|
||||
"is_shadow": r.is_shadow,
|
||||
"cost_usd": r.cost_usd,
|
||||
"step_count": r.step_count,
|
||||
"created_at": r.created_at,
|
||||
"timeout_at": r.timeout_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"runs": runs, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Approvals
|
||||
# =============================================================================
|
||||
|
||||
async def list_approvals(project_id: str | None) -> dict[str, Any]:
|
||||
"""列出所有 waiting_approval 狀態的 runs。"""
|
||||
async with get_db_context("awoooi") as db:
|
||||
stmt = (
|
||||
select(AwoooPRunState)
|
||||
.where(AwoooPRunState.state == "waiting_approval")
|
||||
.order_by(AwoooPRunState.created_at.asc())
|
||||
)
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(AwoooPRunState.project_id == project_id)
|
||||
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
items = [
|
||||
{
|
||||
"run_id": r.run_id,
|
||||
"project_id": r.project_id,
|
||||
"agent_id": r.agent_id,
|
||||
"created_at": r.created_at,
|
||||
"timeout_at": r.timeout_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"approvals": items, "total": total, "items": items}
|
||||
|
||||
|
||||
async def decide_approval(
|
||||
run_id: str,
|
||||
project_id: str,
|
||||
decision: str,
|
||||
approver_id: str,
|
||||
reason: str | None,
|
||||
) -> dict[str, Any]:
|
||||
"""核准或拒絕一個待審核的 run(ADR-116 Gate 5)。"""
|
||||
try:
|
||||
run_uuid = uuid.UUID(run_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"run_id 格式錯誤: {exc}",
|
||||
) from exc
|
||||
|
||||
async with get_db_context(project_id) as db:
|
||||
result = await db.execute(
|
||||
select(AwoooPRunState).where(
|
||||
AwoooPRunState.run_id == run_uuid,
|
||||
AwoooPRunState.project_id == project_id,
|
||||
)
|
||||
)
|
||||
run = result.scalar_one_or_none()
|
||||
|
||||
if run is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"run {run_id!r} 不存在或非此 project 所有",
|
||||
)
|
||||
if run.state != "waiting_approval":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"run {run_id!r} 目前狀態為 {run.state!r},無法審核(需為 waiting_approval)",
|
||||
)
|
||||
|
||||
approval_token_jti: str | None = None
|
||||
new_state: str
|
||||
|
||||
if decision == "approve":
|
||||
token = issue_approval_token(
|
||||
project_id=project_id,
|
||||
run_id=run_id,
|
||||
tool_name="operator_console_approve",
|
||||
approver_id=approver_id,
|
||||
)
|
||||
try:
|
||||
await record_approval(
|
||||
project_id=project_id,
|
||||
run_id=run_id,
|
||||
tool_name="operator_console_approve",
|
||||
approver_id=approver_id,
|
||||
token=token,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"核准記錄失敗: {exc}",
|
||||
) from exc
|
||||
|
||||
await transition(run_uuid, project_id, "running")
|
||||
new_state = "running"
|
||||
|
||||
import base64
|
||||
import json as _json
|
||||
try:
|
||||
p_b64 = token.split(".")[1]
|
||||
padding = 4 - len(p_b64) % 4
|
||||
if padding != 4:
|
||||
p_b64 += "=" * padding
|
||||
payload = _json.loads(base64.urlsafe_b64decode(p_b64))
|
||||
approval_token_jti = payload.get("jti")
|
||||
except Exception:
|
||||
approval_token_jti = None
|
||||
|
||||
else:
|
||||
await transition(
|
||||
run_uuid,
|
||||
project_id,
|
||||
"cancelled",
|
||||
error_code="E-APPR-REJECTED",
|
||||
error_detail=f"operator 拒絕: approver={approver_id!r}, reason={reason!r}",
|
||||
)
|
||||
new_state = "cancelled"
|
||||
|
||||
try:
|
||||
await write_audit(
|
||||
project_id=project_id,
|
||||
action=f"run.approval.{decision}",
|
||||
resource_type="run",
|
||||
resource_id=run_id,
|
||||
details={
|
||||
"approver_id": approver_id,
|
||||
"decision": decision,
|
||||
"reason": reason,
|
||||
"new_state": new_state,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("approval_audit_write_failed", run_id=run_id, error=str(exc))
|
||||
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"decision": decision,
|
||||
"new_state": new_state,
|
||||
"approval_token_jti": approval_token_jti,
|
||||
}
|
||||
495
apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx
Normal file
495
apps/web/src/app/[locale]/awooop/approvals/[run_id]/page.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
// =============================================================================
|
||||
// WOOO AIOps - AwoooP M8 審批決策頁面
|
||||
// =============================================================================
|
||||
// 顯示 Run 詳情,Approve / Reject 各需 Dialog 確認
|
||||
// Reject 需填寫原因
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter, Link } from "@/i18n/routing";
|
||||
import {
|
||||
ShieldCheck,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface RunDetail {
|
||||
run_id: string;
|
||||
project_id: string;
|
||||
agent_id: string;
|
||||
state: string;
|
||||
created_at: string;
|
||||
timeout_at?: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 常數
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
|
||||
// =============================================================================
|
||||
// Sub Components
|
||||
// =============================================================================
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start py-3 border-b border-border last:border-0">
|
||||
<span className="w-32 flex-shrink-0 text-xs font-medium text-muted-foreground uppercase tracking-wider pt-0.5">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm text-foreground font-mono flex-1">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Approve 確認 Dialog
|
||||
function ApproveDialog({
|
||||
runId,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
}: {
|
||||
runId: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="approve-dialog-title"
|
||||
>
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-900/40 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 id="approve-dialog-title" className="text-base font-semibold text-foreground">
|
||||
確認核准
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 hover:bg-accent rounded-lg transition"
|
||||
aria-label="關閉"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-foreground mb-2">確定要核准此 Run?</p>
|
||||
<div className="bg-muted rounded-lg px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Run ID:{" "}
|
||||
<span className="text-brand-accent">{runId.slice(0, 8)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
核准後,Run 將繼續執行後續步驟。此操作無法撤銷。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30 rounded-b-xl">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm text-muted-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-green-600 text-white rounded-lg hover:bg-green-700 transition disabled:opacity-50"
|
||||
>
|
||||
{loading && <RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden="true" />}
|
||||
<CheckCircle className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
確認核准
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reject 確認 Dialog(需填原因)
|
||||
function RejectDialog({
|
||||
runId,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
}: {
|
||||
runId: string;
|
||||
onConfirm: (reason: string) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reject-dialog-title"
|
||||
>
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-md">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-900/40 rounded-lg">
|
||||
<XCircle className="w-5 h-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 id="reject-dialog-title" className="text-base font-semibold text-foreground">
|
||||
確認拒絕
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 hover:bg-accent rounded-lg transition"
|
||||
aria-label="關閉"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="bg-muted rounded-lg px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Run ID:{" "}
|
||||
<span className="text-red-400">{runId.slice(0, 8)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="reject-reason"
|
||||
className="block text-sm font-medium text-foreground mb-2"
|
||||
>
|
||||
拒絕原因
|
||||
<span className="text-red-400 ml-1">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="reject-reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="請輸入拒絕原因..."
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-red-500/50 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
拒絕後,Run 將被終止。此操作無法撤銷。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-5 border-t border-border bg-muted/30 rounded-b-xl">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm text-muted-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onConfirm(reason.trim())}
|
||||
disabled={loading || reason.trim().length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading && <RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden="true" />}
|
||||
<XCircle className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
確認拒絕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export default function ApprovalDecisionPage({
|
||||
params,
|
||||
}: {
|
||||
params: { run_id: string };
|
||||
}) {
|
||||
const { run_id } = params;
|
||||
const router = useRouter();
|
||||
|
||||
const [run, setRun] = useState<RunDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
|
||||
const [showApproveDialog, setShowApproveDialog] = useState(false);
|
||||
const [showRejectDialog, setShowRejectDialog] = useState(false);
|
||||
|
||||
const fetchRun = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
// 使用 approvals API 取得單筆 run 資訊
|
||||
const res = await fetch(`${API_BASE}/api/v1/platform/approvals?run_id=${run_id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const item = Array.isArray(data.items)
|
||||
? data.items.find((r: RunDetail) => r.run_id === run_id)
|
||||
: null;
|
||||
if (item) {
|
||||
setRun(item);
|
||||
} else {
|
||||
setRun({ run_id, project_id: "--", agent_id: "--", state: "--", created_at: "" });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "載入失敗");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [run_id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRun();
|
||||
}, [fetchRun]);
|
||||
|
||||
const handleApprove = async () => {
|
||||
setActionLoading(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/platform/approvals/${run_id}/decide`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
decision: "approve",
|
||||
approver_id: "operator",
|
||||
reason: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setActionSuccess("Run 已成功核准");
|
||||
setShowApproveDialog(false);
|
||||
setTimeout(() => router.push("/awooop/approvals"), 1500);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "操作失敗");
|
||||
setShowApproveDialog(false);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (reason: string) => {
|
||||
setActionLoading(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/platform/approvals/${run_id}/decide`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
decision: "reject",
|
||||
approver_id: "operator",
|
||||
reason,
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setActionSuccess("Run 已拒絕");
|
||||
setShowRejectDialog(false);
|
||||
setTimeout(() => router.push("/awooop/approvals"), 1500);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : "操作失敗");
|
||||
setShowRejectDialog(false);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dialogs */}
|
||||
{showApproveDialog && (
|
||||
<ApproveDialog
|
||||
runId={run_id}
|
||||
onConfirm={handleApprove}
|
||||
onCancel={() => setShowApproveDialog(false)}
|
||||
loading={actionLoading}
|
||||
/>
|
||||
)}
|
||||
{showRejectDialog && (
|
||||
<RejectDialog
|
||||
runId={run_id}
|
||||
onConfirm={handleReject}
|
||||
onCancel={() => setShowRejectDialog(false)}
|
||||
loading={actionLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/awooop/approvals"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" aria-hidden="true" />
|
||||
返回審批佇列
|
||||
</Link>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">審批決策</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{run_id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error / Success States */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">無法載入 Run 資料</p>
|
||||
<p className="text-xs text-red-400 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<p className="text-sm text-red-300">{actionError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionSuccess && (
|
||||
<div className="flex items-center gap-3 p-4 bg-green-900/20 border border-green-800/40 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-400 flex-shrink-0" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-green-300">{actionSuccess}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run Detail Card */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border bg-muted/30">
|
||||
<span className="text-sm font-medium text-foreground">Run 詳情</span>
|
||||
{loading && (
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5">
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center py-3 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="w-32 h-4 bg-muted animate-pulse rounded mr-4" />
|
||||
<div className="h-4 bg-muted animate-pulse rounded w-48" />
|
||||
</div>
|
||||
))
|
||||
) : run ? (
|
||||
<>
|
||||
<DetailRow label="Run ID" value={run.run_id} />
|
||||
<DetailRow label="Project ID" value={run.project_id || "--"} />
|
||||
<DetailRow label="Agent" value={run.agent_id || "--"} />
|
||||
<DetailRow
|
||||
label="狀態"
|
||||
value={
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-yellow-900/40 text-yellow-300 border border-yellow-600/40">
|
||||
{run.state || "--"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="建立時間"
|
||||
value={
|
||||
run.created_at
|
||||
? new Date(run.created_at).toLocaleString("zh-TW")
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
{run.timeout_at && (
|
||||
<DetailRow
|
||||
label="超時時間"
|
||||
value={new Date(run.timeout_at).toLocaleString("zh-TW")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!loading && run && !actionSuccess && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowApproveDialog(true)}
|
||||
disabled={actionLoading}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-3 px-6",
|
||||
"text-sm font-semibold rounded-xl border transition-all duration-150",
|
||||
"bg-green-900/30 text-green-300 border-green-600/40",
|
||||
"hover:bg-green-900/60 hover:border-green-500/60",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500/50"
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" aria-hidden="true" />
|
||||
核准
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRejectDialog(true)}
|
||||
disabled={actionLoading}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-3 px-6",
|
||||
"text-sm font-semibold rounded-xl border transition-all duration-150",
|
||||
"bg-red-900/30 text-red-300 border-red-600/40",
|
||||
"hover:bg-red-900/60 hover:border-red-500/60",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/50"
|
||||
)}
|
||||
>
|
||||
<XCircle className="w-4 h-4" aria-hidden="true" />
|
||||
拒絕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
300
apps/web/src/app/[locale]/awooop/approvals/page.tsx
Normal file
300
apps/web/src/app/[locale]/awooop/approvals/page.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
// =============================================================================
|
||||
// WOOO AIOps - AwoooP M7 審批佇列頁面
|
||||
// =============================================================================
|
||||
// 每 10 秒自動刷新,timeout < 5 分鐘時紅色高亮警示
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
ShieldCheck,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Link } from "@/i18n/routing";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface Approval {
|
||||
run_id: string;
|
||||
project_id: string;
|
||||
agent_id: string;
|
||||
created_at: string;
|
||||
timeout_at: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 常數
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
const AUTO_REFRESH_INTERVAL = 10_000; // 10 秒
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function getRemainingMs(timeoutAt: string | null): number | null {
|
||||
if (!timeoutAt) return null;
|
||||
return new Date(timeoutAt).getTime() - Date.now();
|
||||
}
|
||||
|
||||
function formatRemaining(ms: number): string {
|
||||
if (ms <= 0) return "已超時";
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSec / 60);
|
||||
const seconds = totalSec % 60;
|
||||
if (minutes > 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub Components
|
||||
// =============================================================================
|
||||
|
||||
function TimeoutCell({ timeoutAt }: { timeoutAt: string | null }) {
|
||||
const [remaining, setRemaining] = useState<number | null>(
|
||||
getRemainingMs(timeoutAt)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!timeoutAt) return;
|
||||
const timer = setInterval(() => {
|
||||
setRemaining(getRemainingMs(timeoutAt));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [timeoutAt]);
|
||||
|
||||
if (remaining === null) return <span className="text-muted-foreground text-sm">--</span>;
|
||||
|
||||
const isCritical = remaining <= 5 * 60 * 1000; // 5 分鐘內
|
||||
const isExpired = remaining <= 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 font-mono text-sm",
|
||||
isExpired
|
||||
? "text-red-400"
|
||||
: isCritical
|
||||
? "text-red-300 font-semibold animate-pulse"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
aria-label={`剩餘時間: ${formatRemaining(remaining)}`}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{formatRemaining(remaining)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalRow({ approval }: { approval: Approval }) {
|
||||
const formattedDate = approval.created_at
|
||||
? new Date(approval.created_at).toLocaleDateString("zh-TW", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--";
|
||||
|
||||
const remainingMs = getRemainingMs(approval.timeout_at);
|
||||
const isCritical = remainingMs !== null && remainingMs <= 5 * 60 * 1000;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-border hover:bg-accent/30 transition-colors",
|
||||
isCritical && "bg-red-900/10 hover:bg-red-900/20"
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/awooop/approvals/${approval.run_id}`}
|
||||
className="inline-flex items-center gap-1.5 font-mono text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded border border-brand-accent/20 hover:bg-brand-accent/20 transition-colors group"
|
||||
aria-label={`開啟審批 ${approval.run_id}`}
|
||||
>
|
||||
{approval.run_id.slice(0, 8)}
|
||||
<ArrowRight className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden="true" />
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{approval.project_id || "--"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{approval.agent_id || "--"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{formattedDate}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<TimeoutCell timeoutAt={approval.timeout_at} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export default function ApprovalsPage() {
|
||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchApprovals = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${API_BASE}/api/v1/platform/approvals`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setApprovals(Array.isArray(data.items) ? data.items : []);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "載入失敗");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchApprovals();
|
||||
}, [fetchApprovals]);
|
||||
|
||||
// 10 秒自動刷新
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchApprovals();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [fetchApprovals]);
|
||||
|
||||
const criticalCount = approvals.filter((a) => {
|
||||
const ms = getRemainingMs(a.timeout_at);
|
||||
return ms !== null && ms <= 5 * 60 * 1000;
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
審批佇列
|
||||
{criticalCount > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold bg-red-600 text-white animate-pulse">
|
||||
{criticalCount} 緊急
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "載入中..."
|
||||
: `${approvals.length} 筆待審 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">每 10 秒自動刷新</span>
|
||||
<button
|
||||
onClick={() => { setLoading(true); fetchApprovals(); }}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
|
||||
aria-label="立即重新整理"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} aria-hidden="true" />
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">無法載入審批資料</p>
|
||||
<p className="text-xs text-red-400 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State — 所有審批已處理 */}
|
||||
{!loading && approvals.length === 0 && !error && (
|
||||
<div className="flex flex-col items-center justify-center py-16 bg-card border border-border rounded-xl">
|
||||
<ShieldCheck className="w-12 h-12 text-green-400 mb-3" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-foreground mb-1">審批佇列為空</p>
|
||||
<p className="text-xs text-muted-foreground">目前沒有待審批的 Run</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{(loading || approvals.length > 0) && (
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" role="table" aria-label="審批佇列">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Run ID
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Project ID
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agent
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
建立時間
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
剩餘時間
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-5 bg-muted animate-pulse rounded w-20" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
approvals.map((approval) => (
|
||||
<ApprovalRow key={approval.run_id} approval={approval} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
apps/web/src/app/[locale]/awooop/contracts/page.tsx
Normal file
281
apps/web/src/app/[locale]/awooop/contracts/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
// =============================================================================
|
||||
// WOOO AIOps - AwoooP M3 合約儀表板
|
||||
// =============================================================================
|
||||
// 顯示所有 contract revisions,支援 project_id 篩選
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type ContractStatus = "draft" | "published" | "active";
|
||||
|
||||
interface Contract {
|
||||
id: string;
|
||||
contract_family: string;
|
||||
project_id: string;
|
||||
status: ContractStatus;
|
||||
body_hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Tenant {
|
||||
project_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 常數
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
ContractStatus,
|
||||
{ label: string; bg: string; text: string; border: string }
|
||||
> = {
|
||||
draft: {
|
||||
label: "草稿",
|
||||
bg: "bg-gray-800",
|
||||
text: "text-gray-300",
|
||||
border: "border-gray-600",
|
||||
},
|
||||
published: {
|
||||
label: "已發佈",
|
||||
bg: "bg-blue-900/40",
|
||||
text: "text-blue-300",
|
||||
border: "border-blue-600/40",
|
||||
},
|
||||
active: {
|
||||
label: "生效中",
|
||||
bg: "bg-green-900/40",
|
||||
text: "text-green-300",
|
||||
border: "border-green-600/40",
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Sub Components
|
||||
// =============================================================================
|
||||
|
||||
function StatusBadge({ status }: { status: ContractStatus }) {
|
||||
const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.draft;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium border",
|
||||
config.bg,
|
||||
config.text,
|
||||
config.border
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ContractRow({ contract }: { contract: Contract }) {
|
||||
const formattedDate = contract.created_at
|
||||
? new Date(contract.created_at).toLocaleDateString("zh-TW", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--";
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-foreground font-medium">
|
||||
{contract.contract_family || "--"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{contract.project_id || "--"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={contract.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
{contract.body_hash ? contract.body_hash.slice(0, 8) : "--"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{formattedDate}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export default function ContractsPage() {
|
||||
const [contracts, setContracts] = useState<Contract[]>([]);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [projectFilter, setProjectFilter] = useState<string>("");
|
||||
|
||||
// 取得租戶清單供篩選
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/platform/tenants`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => setTenants(Array.isArray(data.items) ? data.items : []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchContracts = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (projectFilter) params.set("project_id", projectFilter);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/platform/contracts?${params.toString()}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setContracts(Array.isArray(data.items) ? data.items : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "載入失敗");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContracts();
|
||||
}, [fetchContracts]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-brand-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">合約儀表板</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading ? "載入中..." : `共 ${contracts.length} 筆合約版本`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchContracts}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
|
||||
aria-label="重新整理合約清單"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} aria-hidden="true" />
|
||||
重新整理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 p-4 bg-card border border-border rounded-xl">
|
||||
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />
|
||||
<span className="text-sm text-muted-foreground">篩選:</span>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={projectFilter}
|
||||
onChange={(e) => setProjectFilter(e.target.value)}
|
||||
className="appearance-none pl-3 pr-8 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50 cursor-pointer"
|
||||
aria-label="選擇租戶"
|
||||
>
|
||||
<option value="">所有租戶</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.project_id} value={t.project_id}>
|
||||
{t.name || t.project_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">無法載入合約資料</p>
|
||||
<p className="text-xs text-red-400 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" role="table" aria-label="合約清單">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
合約類型
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Project ID
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
狀態
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Hash
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
建立時間
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-5 bg-muted animate-pulse rounded w-24" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : contracts.length === 0 && !error ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-16 text-center">
|
||||
<FileText className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{projectFilter ? "此租戶尚無合約資料" : "尚無合約資料"}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
contracts.map((contract) => (
|
||||
<ContractRow key={contract.id} contract={contract} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
apps/web/src/app/[locale]/awooop/layout.tsx
Normal file
106
apps/web/src/app/[locale]/awooop/layout.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
// =============================================================================
|
||||
// WOOO AIOps - AwoooP Operator Console 佈局
|
||||
// =============================================================================
|
||||
// AwoooP 專屬次級導航列(水平 tab bar),在 DashboardLayout main 區域內運作
|
||||
// 設計方向:工業監控儀表板風格,橘色 accent 與品牌系統一致
|
||||
|
||||
"use client";
|
||||
|
||||
import { Link, usePathname } from "@/i18n/routing";
|
||||
import { Building2, FileText, Activity, ShieldCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// =============================================================================
|
||||
// 導航設定
|
||||
// =============================================================================
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
label: "租戶管理",
|
||||
href: "/awooop/tenants" as const,
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
label: "合約儀表板",
|
||||
href: "/awooop/contracts" as const,
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
label: "Run 監控",
|
||||
href: "/awooop/runs" as const,
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
label: "審批佇列",
|
||||
href: "/awooop/approvals" as const,
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// AwoooP Console 佈局
|
||||
// =============================================================================
|
||||
|
||||
export default function AwoooPLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col">
|
||||
{/* Console Header */}
|
||||
<div className="bg-card border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground tracking-tight">
|
||||
AwoooP Operator Console
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Agent 平台管理員後台 — 租戶 · 合約 · Run · 審批
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-brand-accent/10 text-brand-accent border border-brand-accent/20">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent animate-pulse" />
|
||||
OPERATOR
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<nav className="flex gap-1" role="navigation" aria-label="AwoooP 主要導航">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
pathname?.startsWith(item.href + "/");
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-150",
|
||||
isActive
|
||||
? "bg-brand-accent/15 text-brand-accent border border-brand-accent/30"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 px-6 py-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
apps/web/src/app/[locale]/awooop/page.tsx
Normal file
9
apps/web/src/app/[locale]/awooop/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// =============================================================================
|
||||
// WOOO AIOps - AwoooP Console 入口重導向
|
||||
// =============================================================================
|
||||
|
||||
import { redirect } from "@/i18n/routing";
|
||||
|
||||
export default function AwoooPPage() {
|
||||
redirect("/awooop/tenants");
|
||||
}
|
||||
458
apps/web/src/app/[locale]/awooop/runs/page.tsx
Normal file
458
apps/web/src/app/[locale]/awooop/runs/page.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
// =============================================================================
|
||||
// WOOO AIOps - AwoooP M5 Run 監控頁面
|
||||
// =============================================================================
|
||||
// 每 30 秒自動刷新,RUNNING 狀態顯示 pulse 動畫
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
Activity,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type RunState =
|
||||
| "CREATED"
|
||||
| "QUEUED"
|
||||
| "POLICY_RESOLVED"
|
||||
| "RUNNING"
|
||||
| "WAITING_TOOL"
|
||||
| "WAITING_APPROVAL"
|
||||
| "RESUMED"
|
||||
| "COMPLETED"
|
||||
| "FAILED"
|
||||
| "CANCELLED";
|
||||
|
||||
interface Run {
|
||||
run_id: string;
|
||||
project_id: string;
|
||||
agent_id: string;
|
||||
state: RunState;
|
||||
is_shadow: boolean;
|
||||
token_usage_input: number | null;
|
||||
token_usage_output: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Tenant {
|
||||
project_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RunsResponse {
|
||||
items: Run[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 常數
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
const PER_PAGE = 50;
|
||||
const AUTO_REFRESH_INTERVAL = 30_000; // 30 秒
|
||||
|
||||
const STATE_CONFIG: Record<
|
||||
RunState,
|
||||
{ label: string; bg: string; text: string; border: string; pulse?: boolean }
|
||||
> = {
|
||||
CREATED: {
|
||||
label: "已建立",
|
||||
bg: "bg-gray-800",
|
||||
text: "text-gray-300",
|
||||
border: "border-gray-600",
|
||||
},
|
||||
QUEUED: {
|
||||
label: "排隊中",
|
||||
bg: "bg-gray-800",
|
||||
text: "text-gray-400",
|
||||
border: "border-gray-600",
|
||||
},
|
||||
POLICY_RESOLVED: {
|
||||
label: "策略已解析",
|
||||
bg: "bg-blue-900/40",
|
||||
text: "text-blue-300",
|
||||
border: "border-blue-600/40",
|
||||
},
|
||||
RUNNING: {
|
||||
label: "執行中",
|
||||
bg: "bg-green-900/40",
|
||||
text: "text-green-300",
|
||||
border: "border-green-600/40",
|
||||
pulse: true,
|
||||
},
|
||||
WAITING_TOOL: {
|
||||
label: "等待工具",
|
||||
bg: "bg-yellow-900/40",
|
||||
text: "text-yellow-300",
|
||||
border: "border-yellow-600/40",
|
||||
},
|
||||
WAITING_APPROVAL: {
|
||||
label: "等待審批",
|
||||
bg: "bg-yellow-900/40",
|
||||
text: "text-yellow-300",
|
||||
border: "border-yellow-600/40",
|
||||
},
|
||||
RESUMED: {
|
||||
label: "已恢復",
|
||||
bg: "bg-purple-900/40",
|
||||
text: "text-purple-300",
|
||||
border: "border-purple-600/40",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "已完成",
|
||||
bg: "bg-green-900/40",
|
||||
text: "text-green-400",
|
||||
border: "border-green-600/40",
|
||||
},
|
||||
FAILED: {
|
||||
label: "失敗",
|
||||
bg: "bg-red-900/40",
|
||||
text: "text-red-300",
|
||||
border: "border-red-600/40",
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "已取消",
|
||||
bg: "bg-red-900/30",
|
||||
text: "text-red-400",
|
||||
border: "border-red-700/40",
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Sub Components
|
||||
// =============================================================================
|
||||
|
||||
function RunStateBadge({ state }: { state: RunState }) {
|
||||
const config = STATE_CONFIG[state] ?? STATE_CONFIG.CREATED;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-md text-xs font-mono font-medium border",
|
||||
config.bg,
|
||||
config.text,
|
||||
config.border
|
||||
)}
|
||||
>
|
||||
{config.pulse && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" aria-hidden="true" />
|
||||
)}
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ShadowBadge({ isShadow }: { isShadow: boolean }) {
|
||||
if (!isShadow) return <span className="text-muted-foreground text-sm">--</span>;
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-800 text-gray-400 border border-gray-600">
|
||||
Shadow
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RunRow({ run }: { run: Run }) {
|
||||
const formattedDate = run.created_at
|
||||
? new Date(run.created_at).toLocaleDateString("zh-TW", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "--";
|
||||
|
||||
const totalTokens =
|
||||
(run.token_usage_input ?? 0) + (run.token_usage_output ?? 0);
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded border border-brand-accent/20">
|
||||
{run.run_id.slice(0, 8)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{run.project_id || "--"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
{run.agent_id || "--"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<RunStateBadge state={run.state} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ShadowBadge isShadow={run.is_shadow} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-1 text-sm font-mono text-muted-foreground">
|
||||
{totalTokens > 0 ? (
|
||||
<>
|
||||
<Cpu className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{totalTokens.toLocaleString()}
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
({run.token_usage_input ?? 0}↑ {run.token_usage_output ?? 0}↓)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground font-mono">
|
||||
{formattedDate}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export default function RunsPage() {
|
||||
const [runs, setRuns] = useState<Run[]>([]);
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [projectFilter, setProjectFilter] = useState<string>("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// 取得租戶清單
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/api/v1/platform/tenants`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => setTenants(Array.isArray(data.items) ? data.items : []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const params = new URLSearchParams();
|
||||
if (projectFilter) params.set("project_id", projectFilter);
|
||||
if (statusFilter) params.set("status", statusFilter);
|
||||
params.set("page", String(page));
|
||||
params.set("per_page", String(PER_PAGE));
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/platform/runs/list?${params.toString()}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: RunsResponse = await res.json();
|
||||
setRuns(Array.isArray(data.items) ? data.items : []);
|
||||
setTotal(data.total ?? 0);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "載入失敗");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectFilter, statusFilter, page]);
|
||||
|
||||
// 初次載入
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchRuns();
|
||||
}, [fetchRuns]);
|
||||
|
||||
// 30 秒自動刷新
|
||||
useEffect(() => {
|
||||
intervalRef.current = setInterval(() => {
|
||||
fetchRuns();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [fetchRuns]);
|
||||
|
||||
const totalPages = Math.ceil(total / PER_PAGE);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="w-5 h-5 text-brand-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Run 監控</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading
|
||||
? "載入中..."
|
||||
: `共 ${total} 筆 · 上次更新 ${lastRefresh.toLocaleTimeString("zh-TW")}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">每 30 秒自動刷新</span>
|
||||
<button
|
||||
onClick={() => { setLoading(true); fetchRuns(); }}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
|
||||
aria-label="立即重新整理"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} aria-hidden="true" />
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 p-4 bg-card border border-border rounded-xl flex-wrap">
|
||||
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />
|
||||
<span className="text-sm text-muted-foreground">篩選:</span>
|
||||
|
||||
{/* Project Filter */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={projectFilter}
|
||||
onChange={(e) => { setProjectFilter(e.target.value); setPage(1); }}
|
||||
className="appearance-none pl-3 pr-8 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50 cursor-pointer"
|
||||
aria-label="選擇租戶"
|
||||
>
|
||||
<option value="">所有租戶</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.project_id} value={t.project_id}>
|
||||
{t.name || t.project_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="appearance-none pl-3 pr-8 py-1.5 text-sm bg-background border border-border rounded-lg text-foreground focus:outline-none focus:ring-2 focus:ring-brand-accent/50 cursor-pointer"
|
||||
aria-label="選擇狀態"
|
||||
>
|
||||
<option value="">所有狀態</option>
|
||||
{(Object.keys(STATE_CONFIG) as RunState[]).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATE_CONFIG[s].label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">無法載入 Run 資料</p>
|
||||
<p className="text-xs text-red-400 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" role="table" aria-label="Run 清單">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Run ID
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Project ID
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agent
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
狀態
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Shadow
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Token 用量
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
建立時間
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{Array.from({ length: 7 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-5 bg-muted animate-pulse rounded w-20" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : runs.length === 0 && !error ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-16 text-center">
|
||||
<Activity className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">尚無 Run 資料</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
runs.map((run) => <RunRow key={run.run_id} run={run} />)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border bg-muted/30">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
第 {page} / {totalPages} 頁,共 {total} 筆
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
className="p-1.5 hover:bg-accent rounded transition disabled:opacity-50"
|
||||
aria-label="上一頁"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
className="p-1.5 hover:bg-accent rounded transition disabled:opacity-50"
|
||||
aria-label="下一頁"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
apps/web/src/app/[locale]/awooop/tenants/page.tsx
Normal file
269
apps/web/src/app/[locale]/awooop/tenants/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
// =============================================================================
|
||||
// WOOO AIOps - AwoooP M1 租戶管理頁面
|
||||
// =============================================================================
|
||||
// 顯示所有 tenant 清單,含 migration_mode badge 與 suspended 狀態
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Building2,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
DollarSign,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
type MigrationMode =
|
||||
| "shadow"
|
||||
| "canary"
|
||||
| "read_only"
|
||||
| "suggest"
|
||||
| "auto_remediate";
|
||||
|
||||
interface Tenant {
|
||||
project_id: string;
|
||||
name: string;
|
||||
migration_mode: MigrationMode;
|
||||
budget_limit_usd: number | null;
|
||||
is_suspended: boolean;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
items: Tenant[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 常數
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
|
||||
const MIGRATION_MODE_CONFIG: Record<
|
||||
MigrationMode,
|
||||
{ label: string; bg: string; text: string; border: string }
|
||||
> = {
|
||||
shadow: {
|
||||
label: "Shadow",
|
||||
bg: "bg-gray-800",
|
||||
text: "text-gray-300",
|
||||
border: "border-gray-600",
|
||||
},
|
||||
canary: {
|
||||
label: "Canary",
|
||||
bg: "bg-yellow-900/40",
|
||||
text: "text-yellow-300",
|
||||
border: "border-yellow-600/40",
|
||||
},
|
||||
read_only: {
|
||||
label: "Read Only",
|
||||
bg: "bg-blue-900/40",
|
||||
text: "text-blue-300",
|
||||
border: "border-blue-600/40",
|
||||
},
|
||||
suggest: {
|
||||
label: "Suggest",
|
||||
bg: "bg-purple-900/40",
|
||||
text: "text-purple-300",
|
||||
border: "border-purple-600/40",
|
||||
},
|
||||
auto_remediate: {
|
||||
label: "Auto Remediate",
|
||||
bg: "bg-green-900/40",
|
||||
text: "text-green-300",
|
||||
border: "border-green-600/40",
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Sub Components
|
||||
// =============================================================================
|
||||
|
||||
function MigrationModeBadge({ mode }: { mode: MigrationMode }) {
|
||||
const config = MIGRATION_MODE_CONFIG[mode] ?? MIGRATION_MODE_CONFIG.shadow;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-mono font-medium border",
|
||||
config.bg,
|
||||
config.text,
|
||||
config.border
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SuspendedBadge({ suspended }: { suspended: boolean }) {
|
||||
return suspended ? (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-xs font-medium bg-red-900/40 text-red-300 border border-red-600/40">
|
||||
<Ban className="w-3 h-3" aria-hidden="true" />
|
||||
停用
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-xs font-medium bg-green-900/40 text-green-300 border border-green-600/40">
|
||||
<CheckCircle2 className="w-3 h-3" aria-hidden="true" />
|
||||
正常
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantRow({ tenant }: { tenant: Tenant }) {
|
||||
return (
|
||||
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-sm text-foreground">
|
||||
{tenant.project_id}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-foreground font-medium">{tenant.name || "--"}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<MigrationModeBadge mode={tenant.migration_mode} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground font-mono">
|
||||
{tenant.budget_limit_usd != null ? (
|
||||
<>
|
||||
<DollarSign className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{tenant.budget_limit_usd.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
"--"
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<SuspendedBadge suspended={tenant.is_suspended} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export default function TenantsPage() {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTenants = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${API_BASE}/api/v1/platform/tenants`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: ApiResponse = await res.json();
|
||||
setTenants(Array.isArray(data.items) ? data.items : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "載入失敗");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenants();
|
||||
}, [fetchTenants]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building2 className="w-5 h-5 text-brand-accent" aria-hidden="true" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">租戶管理</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading ? "載入中..." : `共 ${tenants.length} 個租戶`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchTenants}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg transition disabled:opacity-50"
|
||||
aria-label="重新整理租戶清單"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4", loading && "animate-spin")} aria-hidden="true" />
|
||||
重新整理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-900/20 border border-red-800/40 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-300">無法載入租戶資料</p>
|
||||
<p className="text-xs text-red-400 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" role="table" aria-label="租戶清單">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Project ID
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
名稱
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
模式
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
預算上限 (USD)
|
||||
</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
狀態
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<td key={j} className="px-4 py-3">
|
||||
<div className="h-5 bg-muted animate-pulse rounded w-24" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : tenants.length === 0 && !error ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-16 text-center">
|
||||
<Building2 className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">尚無租戶資料</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tenants.map((tenant) => (
|
||||
<TenantRow key={tenant.project_id} tenant={tenant} />
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user