feat(pages): 全部 ComingSoon 頁面升級為真實 UI — 串接真實 API / 空狀態頁面
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 6m47s

- services/topology: 串接 /api/v1/dashboard,顯示服務清單表格與主機拓撲卡片 grid
- notifications: 串接 /api/v1/notifications/channels,404 時顯示空列表
- reports: 串接 /api/v1/stats/incident-summary + /api/v1/stats/resolution-stats,顯示統計卡片
- apm: 乾淨空狀態頁(SignOz 待整合)
- apps/tickets/users/deployments: 空列表表格結構
- billing/compliance/cost/security: 空狀態卡片結構
- help: 靜態系統版本資訊頁
- zh-TW.json + en.json: 新增所有頁面 i18n key(零 hardcode 字串)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-02 23:49:24 +08:00
parent 6266a4fc01
commit e93a50a4b4
16 changed files with 870 additions and 98 deletions

View File

@@ -727,5 +727,122 @@
"latency": "Latency",
"uptime": "Uptime",
"lastCheck": "Last Check"
},
"services": {
"title": "Services",
"subtitle": "All services across hosts",
"name": "Service Name",
"host": "Host",
"status": "Status",
"cpu": "CPU%",
"ram": "RAM%",
"noServices": "No service data available",
"fetchError": "Failed to load services"
},
"topology": {
"title": "Topology",
"subtitle": "Host architecture view",
"noHosts": "No host data available",
"fetchError": "Failed to load host data",
"services": "Services",
"cpu": "CPU",
"ram": "RAM"
},
"notifications": {
"title": "Notifications",
"subtitle": "Notification channel settings",
"channel": "Channel",
"type": "Type",
"status": "Status",
"noChannels": "No notification channels",
"fetchError": "Failed to load notification channels"
},
"reports": {
"title": "Reports",
"subtitle": "Incident statistics summary",
"incidentSummary": "Incident Summary",
"resolutionStats": "Resolution Statistics",
"total": "Total",
"resolved": "Resolved",
"unresolved": "Unresolved",
"avgResolutionTime": "Avg Resolution Time",
"resolutionRate": "Resolution Rate",
"fetchError": "Failed to load report data",
"noData": "No statistics available"
},
"apm": {
"title": "APM",
"subtitle": "Application Performance Monitoring",
"noData": "No APM Data",
"noDataDescription": "APM integration is not yet enabled. Data will appear after SignOz connects."
},
"apps": {
"title": "Applications",
"subtitle": "Application list",
"name": "App Name",
"version": "Version",
"status": "Status",
"noApps": "No application data available"
},
"billing": {
"title": "Billing",
"subtitle": "Cost summary",
"currentMonth": "Current Month",
"totalUsage": "Total Usage",
"noData": "No billing data available"
},
"compliance": {
"title": "Compliance",
"subtitle": "Compliance status overview",
"noData": "No compliance data available"
},
"cost": {
"title": "Cost Analysis",
"subtitle": "Resource cost analysis",
"noData": "No cost data available"
},
"deployments": {
"title": "Deployments",
"subtitle": "Deployment history",
"name": "Service",
"version": "Version",
"status": "Status",
"time": "Time",
"noDeployments": "No deployment records"
},
"help": {
"title": "Help",
"subtitle": "System information",
"version": "Version Info",
"appVersion": "Application Version",
"platform": "Platform",
"docs": "Documentation",
"docsDescription": "Visit AWOOOI Docs for full documentation"
},
"security": {
"title": "Security",
"subtitle": "Security events overview",
"noData": "No security events"
},
"tickets": {
"title": "Tickets",
"subtitle": "Ticket list",
"id": "Ticket ID",
"title_col": "Title",
"status": "Status",
"priority": "Priority",
"noTickets": "No tickets"
},
"users": {
"title": "Users",
"subtitle": "User management",
"name": "Name",
"role": "Role",
"status": "Status",
"noUsers": "No user data available"
},
"emptyState": {
"noData": "--",
"comingSoon": "Integration pending"
}
}

View File

@@ -728,5 +728,122 @@
"latency": "延遲",
"uptime": "可用率",
"lastCheck": "最後檢查"
},
"services": {
"title": "服務目錄",
"subtitle": "所有主機上的服務清單",
"name": "服務名稱",
"host": "主機",
"status": "狀態",
"cpu": "CPU%",
"ram": "RAM%",
"noServices": "目前無服務資料",
"fetchError": "無法取得服務清單"
},
"topology": {
"title": "拓撲圖",
"subtitle": "主機架構視圖",
"noHosts": "目前無主機資料",
"fetchError": "無法取得主機資料",
"services": "服務",
"cpu": "CPU",
"ram": "RAM"
},
"notifications": {
"title": "通知",
"subtitle": "通知頻道設定",
"channel": "頻道",
"type": "類型",
"status": "狀態",
"noChannels": "目前無通知頻道",
"fetchError": "無法取得通知頻道"
},
"reports": {
"title": "報表",
"subtitle": "事件統計摘要",
"incidentSummary": "事件摘要",
"resolutionStats": "解決率統計",
"total": "總計",
"resolved": "已解決",
"unresolved": "未解決",
"avgResolutionTime": "平均解決時間",
"resolutionRate": "解決率",
"fetchError": "無法取得報表資料",
"noData": "目前無統計資料"
},
"apm": {
"title": "APM",
"subtitle": "應用性能監控",
"noData": "暫無 APM 數據",
"noDataDescription": "APM 整合尚未啟用,待 SignOz 連線後自動顯示"
},
"apps": {
"title": "應用",
"subtitle": "應用程式清單",
"name": "應用名稱",
"version": "版本",
"status": "狀態",
"noApps": "目前無應用資料"
},
"billing": {
"title": "帳單",
"subtitle": "費用摘要",
"currentMonth": "本月費用",
"totalUsage": "總用量",
"noData": "目前無帳單資料"
},
"compliance": {
"title": "合規",
"subtitle": "合規狀態概覽",
"noData": "目前無合規資料"
},
"cost": {
"title": "成本分析",
"subtitle": "資源成本分析",
"noData": "目前無成本資料"
},
"deployments": {
"title": "部署管理",
"subtitle": "部署紀錄",
"name": "服務",
"version": "版本",
"status": "狀態",
"time": "時間",
"noDeployments": "目前無部署紀錄"
},
"help": {
"title": "說明",
"subtitle": "系統資訊與說明",
"version": "版本資訊",
"appVersion": "應用程式版本",
"platform": "平台",
"docs": "文件",
"docsDescription": "查閱完整說明文件請造訪 AWOOOI Docs"
},
"security": {
"title": "安全",
"subtitle": "安全事件概覽",
"noData": "目前無安全事件"
},
"tickets": {
"title": "工單",
"subtitle": "工單列表",
"id": "工單 ID",
"title_col": "標題",
"status": "狀態",
"priority": "優先級",
"noTickets": "目前無工單"
},
"users": {
"title": "使用者",
"subtitle": "使用者管理",
"name": "姓名",
"role": "角色",
"status": "狀態",
"noUsers": "目前無使用者資料"
},
"emptyState": {
"noData": "--",
"comingSoon": "資料尚未整合"
}
}

View File

@@ -3,20 +3,35 @@
/**
* APM Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實空狀態頁(無 APM API待 SignOz 整合)
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { Activity } from 'lucide-react'
export default function ApmPage({ params }: { params: { locale: string } }) {
const t = useTranslations('apm')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={Activity}
title="APM"
description="應用性能監控SignOz 整合中"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
<div style={{ padding: '60px 24px', textAlign: 'center' }}>
<div style={{ fontSize: 32, color: '#e0ddd4', marginBottom: 16 }}></div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace', marginBottom: 8 }}>{t('noData')}</div>
<div style={{ fontSize: 12, color: '#87867f', fontFamily: 'var(--font-body), monospace', maxWidth: 340, margin: '0 auto' }}>{t('noDataDescription')}</div>
</div>
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,44 @@
/**
* 應用 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實空列表頁(無 apps API
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { AppWindow } from 'lucide-react'
export default function AppsPage({ params }: { params: { locale: string } }) {
const t = useTranslations('apps')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={AppWindow}
title="應用"
description="應用程式目錄"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('name'), t('version'), t('status')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td colSpan={3} style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noApps')}</td>
</tr>
</tbody>
</table>
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,40 @@
/**
* 帳單 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實空頁(無 billing API
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { CreditCard } from 'lucide-react'
export default function BillingPage({ params }: { params: { locale: string } }) {
const t = useTranslations('billing')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={CreditCard}
title="帳單"
description="帳單與用量查詢"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{[t('currentMonth'), t('totalUsage')].map(label => (
<div key={label} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '20px 24px', minWidth: 160 }}>
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 6 }}>{label}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>--</div>
</div>
))}
</div>
<div style={{ marginTop: 16, background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,31 @@
/**
* 合規 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實空頁(無 compliance API
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { ClipboardCheck } from 'lucide-react'
export default function CompliancePage({ params }: { params: { locale: string } }) {
const t = useTranslations('compliance')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={ClipboardCheck}
title="合規"
description="合規檢查與稽核報告"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,31 @@
/**
* 成本分析 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實空頁(無 cost API
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { DollarSign } from 'lucide-react'
export default function CostPage({ params }: { params: { locale: string } }) {
const t = useTranslations('cost')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={DollarSign}
title="成本分析"
description="雲端成本分析報表"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,44 @@
/**
* 部署管理 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實空列表頁(無 deployments API
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { Package } from 'lucide-react'
export default function DeploymentsPage({ params }: { params: { locale: string } }) {
const t = useTranslations('deployments')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={Package}
title="部署管理"
description="K8s 部署管理介面"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('name'), t('version'), t('status'), t('time')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td colSpan={4} style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noDeployments')}</td>
</tr>
</tbody>
</table>
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,55 @@
/**
* 說明文件 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為靜態系統版本資訊頁
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { HelpCircle } from 'lucide-react'
export default function HelpPage({ params }: { params: { locale: string } }) {
const t = useTranslations('help')
const tb = useTranslations('brand')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={HelpCircle}
title="說明文件"
description="操作手冊與 FAQ"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
{/* Version Info Card */}
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('version')}
</div>
<div style={{ padding: '16px 14px', fontFamily: 'var(--font-body), monospace' }}>
{[
{ label: t('appVersion'), value: tb('version') },
{ label: t('platform'), value: tb('name') },
{ label: tb('environment'), value: tb('environment') },
].map(row => (
<div key={row.label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '0.5px solid #f0eee7' }}>
<span style={{ fontSize: 13, color: '#87867f' }}>{row.label}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: '#141413' }}>{row.value}</span>
</div>
))}
</div>
</div>
{/* Docs Card */}
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('docs')}
</div>
<div style={{ padding: '16px 14px', fontSize: 13, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>
{t('docsDescription')}
</div>
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,95 @@
/**
* 通知設定 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實 UI串接 /api/v1/notifications/channels
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { BellRing } from 'lucide-react'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface Channel {
name: string
type: string
status: string
}
const statusColor = (s: string) => {
if (s === 'active' || s === 'enabled') return '#4caf50'
if (s === 'warning') return '#ff9800'
if (s === 'error' || s === 'disabled') return '#f44336'
return '#87867f'
}
export default function NotificationsPage({ params }: { params: { locale: string } }) {
const t = useTranslations('notifications')
const tc = useTranslations('common')
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
fetch(`${API_BASE}/api/v1/notifications/channels`)
.then(r => {
if (!r.ok) throw new Error('not found')
return r.json()
})
.then(data => { setChannels(Array.isArray(data) ? data : (data?.channels ?? [])) })
.catch(() => { setChannels([]) })
.finally(() => setLoading(false))
}, [])
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={BellRing}
title="通知設定"
description="通知偏好與訂閱設定"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
{loading && (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{tc('loading')}</div>
)}
{!loading && channels.length === 0 && (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noChannels')}</div>
)}
{!loading && channels.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('channel'), t('type'), t('status')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{channels.map((ch, i) => (
<tr key={i} style={{ borderBottom: '0.5px solid #e0ddd4' }}>
<td style={{ padding: '10px 14px', color: '#141413', fontWeight: 500 }}>{ch.name}</td>
<td style={{ padding: '10px 14px', color: '#87867f' }}>{ch.type}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: statusColor(ch.status), display: 'inline-block' }} />
<span style={{ color: '#141413' }}>{ch.status}</span>
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,105 @@
/**
* 報表 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實 UI串接 /api/v1/stats/incident-summary + /api/v1/stats/resolution-stats
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { FileText } from 'lucide-react'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface IncidentSummary {
total?: number
resolved?: number
unresolved?: number
[key: string]: unknown
}
interface ResolutionStats {
resolutionRate?: number
avgResolutionTime?: string | number
[key: string]: unknown
}
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, padding: '20px 24px', minWidth: 140 }}>
<div style={{ fontSize: 11, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 6 }}>{label}</div>
<div style={{ fontSize: 28, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{value}</div>
</div>
)
}
export default function ReportsPage({ params }: { params: { locale: string } }) {
const t = useTranslations('reports')
const tc = useTranslations('common')
const [summary, setSummary] = useState<IncidentSummary | null>(null)
const [resolution, setResolution] = useState<ResolutionStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
useEffect(() => {
setLoading(true)
setError(false)
Promise.all([
fetch(`${API_BASE}/api/v1/stats/incident-summary`).then(r => r.json()).catch(() => null),
fetch(`${API_BASE}/api/v1/stats/resolution-stats`).then(r => r.json()).catch(() => null),
])
.then(([s, r]) => {
setSummary(s)
setResolution(r)
})
.catch(() => setError(true))
.finally(() => setLoading(false))
}, [])
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={FileText}
title="報表"
description="週期性報告系統"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '40px', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{tc('loading')}</div>
)}
{!loading && error && (
<div style={{ textAlign: 'center', padding: '40px', color: '#f44336', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('fetchError')}</div>
)}
{!loading && !error && (
<>
{/* Incident Summary Section */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{t('incidentSummary')}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
<StatCard label={t('total')} value={summary?.total ?? '--'} />
<StatCard label={t('resolved')} value={summary?.resolved ?? '--'} />
<StatCard label={t('unresolved')} value={summary?.unresolved ?? '--'} />
</div>
</div>
{/* Resolution Stats Section */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{t('resolutionStats')}</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
<StatCard label={t('resolutionRate')} value={resolution?.resolutionRate != null ? `${resolution.resolutionRate}%` : '--'} />
<StatCard label={t('avgResolutionTime')} value={resolution?.avgResolutionTime ?? '--'} />
</div>
</div>
</>
)}
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,31 @@
/**
* 安全 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實空頁(無 security API
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { Shield } from 'lucide-react'
export default function SecurityPage({ params }: { params: { locale: string } }) {
const t = useTranslations('security')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={Shield}
title="安全"
description="安全事件與威脅偵測"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noData')}</div>
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,118 @@
/**
* 服務目錄 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實 UI串接 /api/v1/dashboard
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { Server } from 'lucide-react'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface ServiceItem {
name: string
host: string
status: string
cpu?: number
ram?: number
}
const statusColor = (s: string) => {
if (s === 'healthy' || s === 'running') return '#4caf50'
if (s === 'warning') return '#ff9800'
if (s === 'critical' || s === 'error') return '#f44336'
return '#87867f'
}
export default function ServicesPage({ params }: { params: { locale: string } }) {
const t = useTranslations('services')
const tc = useTranslations('common')
const [services, setServices] = useState<ServiceItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
useEffect(() => {
setLoading(true)
setError(false)
fetch(`${API_BASE}/api/v1/dashboard`)
.then(r => r.json())
.then(data => {
const hosts: { name: string; services?: { name: string; status: string; cpu?: number; ram?: number }[] }[] = data?.hosts ?? []
const list: ServiceItem[] = []
hosts.forEach(h => {
(h.services ?? []).forEach(s => {
list.push({ name: s.name, host: h.name, status: s.status, cpu: s.cpu, ram: s.ram })
})
})
setServices(list)
})
.catch(() => setError(true))
.finally(() => setLoading(false))
}, [])
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={Server}
title="服務目錄"
description="服務登錄中心"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
{loading && (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
{tc('loading')}
</div>
)}
{!loading && error && (
<div style={{ padding: '32px', textAlign: 'center', color: '#f44336', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
{t('fetchError')}
</div>
)}
{!loading && !error && services.length === 0 && (
<div style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
{t('noServices')}
</div>
)}
{!loading && !error && services.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('name'), t('host'), t('status'), t('cpu'), t('ram')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
{services.map((s, i) => (
<tr key={i} style={{ borderBottom: '0.5px solid #e0ddd4' }}>
<td style={{ padding: '10px 14px', color: '#141413', fontWeight: 500 }}>{s.name}</td>
<td style={{ padding: '10px 14px', color: '#87867f' }}>{s.host}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: statusColor(s.status), display: 'inline-block' }} />
<span style={{ color: '#141413' }}>{s.status}</span>
</span>
</td>
<td style={{ padding: '10px 14px', color: '#141413' }}>{s.cpu != null ? `${s.cpu}%` : '--'}</td>
<td style={{ padding: '10px 14px', color: '#141413' }}>{s.ram != null ? `${s.ram}%` : '--'}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,44 @@
/**
* 工單 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實空列表頁(無 tickets API
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { Ticket } from 'lucide-react'
export default function TicketsPage({ params }: { params: { locale: string } }) {
const t = useTranslations('tickets')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={Ticket}
title="工單"
description="工單系統整合中"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('id'), t('title_col'), t('status'), t('priority')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td colSpan={4} style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noTickets')}</td>
</tr>
</tbody>
</table>
</div>
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,101 @@
/**
* 拓撲圖 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實 UI串接 /api/v1/dashboard hosts
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { GitBranch } from 'lucide-react'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface HostItem {
name: string
status: string
cpu?: number
ram?: number
services?: { name: string; status: string }[]
}
const statusColor = (s: string) => {
if (s === 'healthy' || s === 'running') return '#4caf50'
if (s === 'warning') return '#ff9800'
if (s === 'critical' || s === 'error') return '#f44336'
return '#87867f'
}
export default function TopologyPage({ params }: { params: { locale: string } }) {
const t = useTranslations('topology')
const tc = useTranslations('common')
const [hosts, setHosts] = useState<HostItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
useEffect(() => {
setLoading(true)
setError(false)
fetch(`${API_BASE}/api/v1/dashboard`)
.then(r => r.json())
.then(data => { setHosts(data?.hosts ?? []) })
.catch(() => setError(true))
.finally(() => setLoading(false))
}, [])
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={GitBranch}
title="拓撲圖"
description="服務依賴拓撲,開發中"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '40px', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{tc('loading')}</div>
)}
{!loading && error && (
<div style={{ textAlign: 'center', padding: '40px', color: '#f44336', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('fetchError')}</div>
)}
{!loading && !error && hosts.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noHosts')}</div>
)}
{!loading && !error && hosts.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 16 }}>
{hosts.map((host, i) => (
<div key={i} style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: statusColor(host.status), display: 'inline-block' }} />
<span style={{ fontSize: 13, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{host.name}</span>
</div>
<div style={{ padding: '12px 14px', display: 'flex', gap: 16 }}>
<div>
<div style={{ fontSize: 10, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>{t('cpu')}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{host.cpu != null ? `${host.cpu}%` : '--'}</div>
</div>
<div>
<div style={{ fontSize: 10, color: '#87867f', fontFamily: 'var(--font-body), monospace' }}>{t('ram')}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>{host.ram != null ? `${host.ram}%` : '--'}</div>
</div>
</div>
{(host.services ?? []).length > 0 && (
<div style={{ padding: '0 14px 12px' }}>
<div style={{ fontSize: 10, color: '#87867f', fontFamily: 'var(--font-body), monospace', marginBottom: 6 }}>{t('services')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{(host.services ?? []).map((svc, j) => (
<span key={j} style={{ fontSize: 10, padding: '2px 6px', borderRadius: 4, border: '0.5px solid #e0ddd4', color: '#141413', background: '#faf9f3', fontFamily: 'var(--font-body), monospace' }}>
<span style={{ display: 'inline-block', width: 5, height: 5, borderRadius: '50%', background: statusColor(svc.status), marginRight: 4, verticalAlign: 'middle' }} />
{svc.name}
</span>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</AppLayout>
)
}

View File

@@ -3,20 +3,44 @@
/**
* 使用者管理 Page
* @created 2026-04-01 ogt - 路由佔位 (awaiting implementation)
* @updated 2026-04-02 ogt - 升級為真實空列表頁(無 users API
*/
import { useTranslations } from 'next-intl'
import { AppLayout } from '@/components/layout'
import { ComingSoon } from '@/components/layout'
import { Users } from 'lucide-react'
export default function UsersPage({ params }: { params: { locale: string } }) {
const t = useTranslations('users')
return (
<AppLayout locale={params.locale}>
<ComingSoon
icon={Users}
title="使用者管理"
description="RBAC 使用者與角色管理"
/>
<div style={{ padding: '24px', background: '#f5f4ed', minHeight: '100%' }}>
<div style={{ marginBottom: '20px' }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: '#141413', margin: 0, fontFamily: 'var(--font-body), monospace' }}>{t('title')}</h1>
<p style={{ fontSize: 12, color: '#87867f', margin: '4px 0 0', fontFamily: 'var(--font-body), monospace' }}>{t('subtitle')}</p>
</div>
<div style={{ background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, fontWeight: 700, padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4', background: '#faf9f3', fontFamily: 'var(--font-body), monospace', color: '#141413' }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', display: 'inline-block' }} />
{t('title')}
</div>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>
<thead>
<tr style={{ background: '#faf9f3' }}>
{[t('name'), t('role'), t('status')].map(col => (
<th key={col} style={{ padding: '8px 14px', textAlign: 'left', fontWeight: 600, color: '#87867f', borderBottom: '0.5px solid #e0ddd4', fontSize: 11 }}>{col}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td colSpan={3} style={{ padding: '32px', textAlign: 'center', color: '#87867f', fontFamily: 'var(--font-body), monospace', fontSize: 13 }}>{t('noUsers')}</td>
</tr>
</tbody>
</table>
</div>
</div>
</AppLayout>
)
}