feat: monitor-ai 告警监控中心完整实现
Phase 1: 共享库(auth/audit/wechat/alert) Phase 2: 项目骨架(Next.js 15 + Tailwind v4 + 设计系统) Phase 3: 认证系统(OIDC SSO + LDAP + localadmin + RBAC) Phase 4: 服务管理 + MonitorWorker 后台引擎 Phase 5: 告警渠道 + WeChatPusher + AlertManager Phase 6: 仪表盘 + 状态历史 + 告警历史 Phase 7: 部署脚本 + 文档 Phase 8: TopBar 布局对齐 + 暗色模式修复 独立审查通过(2 轮零 CRITICAL/HIGH 问题)
This commit is contained in:
commit
89acbe3bf6
|
|
@ -0,0 +1,23 @@
|
|||
# monitor-ai 环境变量模板
|
||||
NODE_ENV=production
|
||||
DATABASE_PATH=/data/monitor.db
|
||||
MONITOR_MODE=local
|
||||
|
||||
# OIDC SSO
|
||||
AUTHELIA_URL=https://sso.tlyq.ai
|
||||
OIDC_CLIENT_ID=monitor-oidc
|
||||
OIDC_CLIENT_SECRET=<由部署脚本生成>
|
||||
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
|
||||
|
||||
# 共享 JWT(与 OA/assets/issue 相同)
|
||||
JWT_SECRET=<与全站一致>
|
||||
COOKIE_DOMAIN=.tlyq.ai
|
||||
|
||||
# LLDAP
|
||||
LDAP_URL=ldap://ldap-ai:3890
|
||||
|
||||
# localadmin 密码(首次部署时 openssl rand -hex 16 生成)
|
||||
LOCALADMIN_PASSWORD=<生成>
|
||||
|
||||
# 自签名证书
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
node_modules/
|
||||
.next/
|
||||
data/*.db
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Changelog
|
||||
|
||||
## v2026.07.01
|
||||
|
||||
### 新增
|
||||
|
||||
- **独立告警监控中心**:monitor-ai 初始版本,监控 tlyq.ai 全部容器健康状态
|
||||
- **统一管理布局**:Sidebar + ThemeProvider + 内容区,遵循 tlyq 设计系统规范(Slate 色板 + Indigo-600 主色调,浅色/暗色/自动三种主题)
|
||||
- **OIDC SSO 统一认证**:通过 Authelia 进行 PKCE 授权码流程 + LDAP 本地登录 + localadmin 超级管理员
|
||||
- **RBAC 权限模块**:admin/editor/viewer 三级角色,11 个权限点,localadmin 硬编码绕过
|
||||
- **审计日志**:所有写操作记录,180 天自动清理
|
||||
- **MonitorWorker 健康检查引擎**:30 秒 tick 间隔,HTTP + Docker 可插拔检查器,抖动检测(10 分钟窗口 >= 5 次切换),持续异常 30 分钟提醒,恢复通知绕过免打扰
|
||||
- **告警管理引擎**:三阶段决策(级别匹配 → 免打扰 → 冷却),冷却状态持久化到数据库,容器重启不丢失
|
||||
- **企业微信 Webhook 推送**:markdown 格式,5 秒超时,支持测试推送
|
||||
- **四层仪表盘**:标题行 + 状态统计条 + KPI 统计条 + 4 列服务卡片网格
|
||||
- **服务管理页面**:CRUD + 手动检查 + 状态查看
|
||||
- **告警设置页面**:告警渠道管理(Webhook URL、级别过滤、免打扰时段、冷却时间)
|
||||
- **告警历史页面**:告警推送记录(分页,按类型/级别筛选)
|
||||
- **状态变更历史页面**:服务状态变更事件(分页,按服务筛选)
|
||||
- **共享库**:`shared/lib/`(auth/、audit/、wechat/、alert/、db/)+ `shared/ui/`(9 个 React 组件 + 统一登录页)
|
||||
- **部署脚本**:`deploy-monitor.sh`(支持本地/远程/开发三种模式,首次部署自动生成密钥)
|
||||
- **健康检查兜底**:`monitor-watchdog.sh`(crontab 每 5 分钟,自身异常时直接 curl webhook)
|
||||
- **Docker 双进程守护**:`entrypoint.sh` 同时启动 Worker + Server,任一退出则容器退出
|
||||
|
|
@ -0,0 +1,501 @@
|
|||
# CLAUDE.md — monitor.tlyq.ai 告警监控中心
|
||||
|
||||
## 项目概述
|
||||
|
||||
monitor-ai 是独立部署的统一告警监控中心,监控 tlyq.ai 基础设施所有容器(Authelia、Redis、LLDAP、OA、assets、issue、Gitea、Nginx、www、cloud、token)的健康状态,通过企业微信 Webhook 推送告警。部署在腾讯云(txjp 服务器),域名 `monitor.tlyq.ai`。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 属性 | 值 |
|
||||
|------|-----|
|
||||
| 站点域名 | `monitor.tlyq.ai` |
|
||||
| 服务器 | txjp(IP: 43.133.38.210) |
|
||||
| 代码路径 | `/root/docker/monitor-ai/` |
|
||||
| 本地端口 | 6181 |
|
||||
| 容器名 | `monitor-ai` |
|
||||
| 数据库 | SQLite:`data/monitor.db` |
|
||||
| 默认账号 | `localadmin` / 首次部署自动生成 |
|
||||
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
cd /Users/niuniu/programs/docker/monitor-ai
|
||||
npm run dev # 本地开发(http://localhost:6181)
|
||||
npm run build # 生产构建
|
||||
npm run start # 启动生产服务
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 技术 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Next.js | 15 App Router | Web 框架(standalone 输出) |
|
||||
| SQLite | 3.x | 数据库(通过 `execFileSync` + `sqlite3` CLI) |
|
||||
| Tailwind CSS | v4 | 样式(`@tailwindcss/postcss`) |
|
||||
| lucide-react | ^1.8.0 | 图标库 |
|
||||
| ldapts | ^6.0.0 | LDAP 认证客户端 |
|
||||
| openid-client | ^5.7.1 | OIDC 认证 |
|
||||
| tsx | ^4.0.0 | Worker TypeScript 执行 |
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
monitor-ai/
|
||||
├── Dockerfile # 两阶段构建(node:22-alpine)
|
||||
├── docker-compose.yml # 容器编排(webnet external)
|
||||
├── entrypoint.sh # Docker 入口(双进程守护:Worker + Server)
|
||||
├── next.config.ts # output: standalone, transpilePackages
|
||||
├── shared -> ../shared # 共享库符号链接
|
||||
├── scripts/
|
||||
│ ├── monitor-worker.ts # Worker 独立入口
|
||||
│ ├── deploy-monitor.sh # 部署脚本
|
||||
│ └── monitor-watchdog.sh # 健康检查兜底脚本
|
||||
├── src/
|
||||
│ ├── middleware.ts # Edge Runtime 路由守卫
|
||||
│ ├── app/
|
||||
│ │ ├── page.tsx # 仪表盘(四层:标题 + 状态条 + KPI + 服务卡片)
|
||||
│ │ ├── login/page.tsx # 登录页
|
||||
│ │ ├── services/page.tsx # 服务管理
|
||||
│ │ ├── settings/page.tsx # 告警设置
|
||||
│ │ ├── alerts/page.tsx # 告警历史
|
||||
│ │ ├── status-history/page.tsx # 状态变更历史
|
||||
│ │ ├── admin/ # 管理页面(users/roles/audit-logs)
|
||||
│ │ └── api/ # API 路由
|
||||
│ ├── components/
|
||||
│ │ ├── Sidebar.tsx # 侧边栏导航
|
||||
│ │ ├── ThemeProvider.tsx # 主题上下文
|
||||
│ │ └── ThemeToggle.tsx # 主题切换
|
||||
│ └── lib/
|
||||
│ ├── config.ts # 配置常量
|
||||
│ ├── db.ts # 数据库操作封装(execFileSync + escapeSql)
|
||||
│ ├── auth-config.ts # 认证配置
|
||||
│ ├── permissions.ts # RBAC 权限定义
|
||||
│ ├── audit.ts # 审计日志封装
|
||||
│ └── monitor-worker.ts # MonitorWorker 核心类
|
||||
└── data/
|
||||
└── monitor.db # SQLite 数据库
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/lib/monitor-worker.ts` | MonitorWorker 核心类(30s tick,健康检查 + 状态管理 + 告警推送) |
|
||||
| `src/lib/db.ts` | SQLite 操作封装(execFileSync + escapeSql,无 ORM) |
|
||||
| `src/lib/permissions.ts` | RBAC 权限定义(11 个权限点,admin/editor/viewer/localadmin) |
|
||||
| `src/lib/audit.ts` | 审计日志封装(调用 `@shared/lib/audit`) |
|
||||
| `src/lib/auth-config.ts` | 认证配置(OIDC + LDAP) |
|
||||
| `src/middleware.ts` | Edge Runtime 路由守卫(Cookie JWT 验证,公开/管理路径控制) |
|
||||
| `scripts/monitor-worker.ts` | Worker 独立入口(信号处理、优雅退出) |
|
||||
| `entrypoint.sh` | Docker 入口(双进程守护:Worker + Server,任一退出则容器退出) |
|
||||
| `shared/lib/alert/` | 告警引擎(AlertManager、HealthChecker、类型定义) |
|
||||
| `shared/lib/wechat/` | 企业微信推送(WeChatPusher、消息格式化) |
|
||||
|
||||
---
|
||||
|
||||
## 数据库 Schema
|
||||
|
||||
### 表概览
|
||||
|
||||
| 表名 | 说明 | 来源 |
|
||||
|------|------|------|
|
||||
| `services` | 被监控服务列表 | `@shared/lib/db/alert-schema` |
|
||||
| `alert_channels` | Webhook 告警渠道配置 | `@shared/lib/db/alert-schema` |
|
||||
| `alert_settings` | 全局设置 + 冷却状态持久化 | `@shared/lib/db/alert-schema` |
|
||||
| `status_history` | 服务状态变更事件 | `@shared/lib/db/alert-schema` |
|
||||
| `alert_history` | 告警推送记录 | `@shared/lib/db/alert-schema` |
|
||||
| `users` | 用户账号 | `src/lib/db.ts`(内联) |
|
||||
| `permissions` | 权限定义 | `src/lib/db.ts`(内联) |
|
||||
| `role_permissions` | 角色-权限映射 | `src/lib/db.ts`(内联) |
|
||||
| `audit_logs` | 审计日志 | `@shared/lib/audit/audit-schema` |
|
||||
|
||||
### services 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | 主键 |
|
||||
| `name` | TEXT | 服务名称 |
|
||||
| `category` | TEXT | 分类(endpoint / container / custom) |
|
||||
| `alert_level` | TEXT | 告警级别(critical / warning / info) |
|
||||
| `checks` | TEXT | 检查配置(JSON 数组,HealthCheckConfig[]) |
|
||||
| `check_interval` | INTEGER | 检查间隔秒数(默认 30) |
|
||||
| `check_timeout` | INTEGER | 检查超时秒数(默认 10) |
|
||||
| `enabled` | INTEGER | 是否启用(默认 1) |
|
||||
| `current_status` | TEXT | 当前状态(normal / abnormal / unknown) |
|
||||
| `status_since` | TEXT | 状态持续起始时间 |
|
||||
| `display_order` | INTEGER | 显示排序 |
|
||||
|
||||
### alert_channels 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | 主键 |
|
||||
| `name` | TEXT | 渠道名称 |
|
||||
| `channel_type` | TEXT | 渠道类型(默认 'wechat') |
|
||||
| `webhook_url` | TEXT | Webhook 地址 |
|
||||
| `enabled` | INTEGER | 是否启用 |
|
||||
| `level_critical` | INTEGER | 是否接收 critical 告警 |
|
||||
| `level_warning` | INTEGER | 是否接收 warning 告警 |
|
||||
| `level_info` | INTEGER | 是否接收 info 告警 |
|
||||
| `quiet_enabled` | INTEGER | 是否启用免打扰 |
|
||||
| `quiet_start` | TEXT | 免打扰开始时间 |
|
||||
| `quiet_end` | TEXT | 免打扰结束时间 |
|
||||
| `quiet_bypass_critical` | INTEGER | critical 是否绕过免打扰 |
|
||||
| `cooldown_minutes` | INTEGER | 冷却时间(分钟) |
|
||||
|
||||
### status_history 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | 主键 |
|
||||
| `service_id` | INTEGER FK | 关联服务 |
|
||||
| `from_status` | TEXT | 原状态 |
|
||||
| `to_status` | TEXT | 新状态 |
|
||||
| `started_at` | TEXT | 状态开始时间 |
|
||||
| `ended_at` | TEXT | 状态结束时间 |
|
||||
| `duration_seconds` | INTEGER | 持续时长 |
|
||||
| `error_message` | TEXT | 错误信息 |
|
||||
| `failed_checks` | TEXT | 失败检查详情(JSON) |
|
||||
| `truncated_by_restart` | INTEGER | 是否被重启截断 |
|
||||
|
||||
### alert_history 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | 主键 |
|
||||
| `channel_id` | INTEGER FK | 告警渠道 |
|
||||
| `service_id` | INTEGER FK | 关联服务 |
|
||||
| `status_event_id` | INTEGER FK | 关联状态事件 |
|
||||
| `alert_type` | TEXT | 告警类型(alert / recovery / flapping) |
|
||||
| `level` | TEXT | 告警级别 |
|
||||
| `title` | TEXT | 标题 |
|
||||
| `content` | TEXT | 内容 |
|
||||
| `sent_at` | TEXT | 发送时间 |
|
||||
| `success` | INTEGER | 是否成功 |
|
||||
| `response_code` | INTEGER | HTTP 响应码 |
|
||||
|
||||
### users 表
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | INTEGER PK | 主键 |
|
||||
| `username` | TEXT UNIQUE | 用户名 |
|
||||
| `display_name` | TEXT | 显示名 |
|
||||
| `email` | TEXT | 邮箱 |
|
||||
| `role` | TEXT | 角色(admin / editor / viewer) |
|
||||
| `password_hash` | TEXT | 密码哈希 |
|
||||
| `is_active` | INTEGER | 是否启用 |
|
||||
|
||||
默认用户:`localadmin`(admin 角色,BCrypt 密码哈希)
|
||||
|
||||
### 预置角色
|
||||
|
||||
| 角色 | 权限 | 说明 |
|
||||
|------|------|------|
|
||||
| `admin` | 全部 11 个权限 | 管理员 |
|
||||
| `editor` | dashboard:view, services:view, alerts:view, status_history:view, status_history:stats | 编辑者 |
|
||||
| `viewer` | dashboard:view, services:view, status_history:view | 只读 |
|
||||
| `localadmin` | 硬编码绕过(hasPermission 始终返回 true) | 应急管理员 |
|
||||
|
||||
### 权限列表
|
||||
|
||||
`dashboard:view`、`services:view`、`services:manage`、`alerts:view`、`alerts:manage`、`status_history:view`、`status_history:stats`、`audit:view`、`users:view`、`users:manage`、`roles:manage`
|
||||
|
||||
---
|
||||
|
||||
## API 路由
|
||||
|
||||
### 公开 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/health` | 自身健康检查(返回 `{status:'OK'}`) |
|
||||
|
||||
### 认证
|
||||
|
||||
登录逻辑:OIDC SSO 优先 + LDAP 本地登录 + localadmin 应急用户。
|
||||
登录成功签发 `tlyq_session` cookie(共享 JWT,domain=.tlyq.ai)。
|
||||
中间件验证 JWT payload(Edge Runtime,不解密签名,仅解码 payload 检查过期时间)。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/auth/login/oidc` | OIDC SSO 重定向到 Authelia |
|
||||
| POST | `/api/auth/login` | LDAP 或 localadmin 登录 |
|
||||
| GET | `/api/auth/callback` | OIDC 回调处理 |
|
||||
| POST | `/api/auth/logout` | 登出(清除 cookie,重定向 Authelia end_session) |
|
||||
| GET | `/api/auth/me` | 当前用户信息 |
|
||||
|
||||
### 服务管理
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/services` | 登录 | 服务列表(含解析后的 JSON checks) |
|
||||
| POST | `/api/services` | services:manage | 新增服务 |
|
||||
| PUT | `/api/services/[id]` | services:manage | 更新服务 |
|
||||
| DELETE | `/api/services/[id]` | services:manage | 删除服务 |
|
||||
| POST | `/api/services/[id]/check` | services:manage | 手动触发单次健康检查 |
|
||||
|
||||
### 告警渠道
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/alert-channels` | alerts:manage | 渠道列表(webhook_url 脱敏) |
|
||||
| POST | `/api/alert-channels` | alerts:manage | 新增渠道 |
|
||||
| PUT | `/api/alert-channels/[id]` | alerts:manage | 更新渠道 |
|
||||
| DELETE | `/api/alert-channels/[id]` | alerts:manage | 删除渠道 |
|
||||
| POST | `/api/alert-channels/[id]/test` | alerts:manage | 测试推送 |
|
||||
|
||||
### 告警历史 / 状态历史
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/alerts` | alerts:view | 告警历史(分页) |
|
||||
| GET | `/api/status-history` | 登录 | 状态变更历史(分页,可按 service_id 筛选) |
|
||||
| GET | `/api/status` | 登录 | 所有服务实时状态汇总 |
|
||||
|
||||
### 管理
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/admin/worker-status` | dashboard:view | Worker 运行状态 |
|
||||
|
||||
---
|
||||
|
||||
## Worker 架构
|
||||
|
||||
### MonitorWorker 核心类
|
||||
|
||||
位置:`src/lib/monitor-worker.ts`
|
||||
|
||||
```
|
||||
MonitorWorker
|
||||
├── HealthChecker(@shared/lib/alert/health-checker)
|
||||
│ ├── HttpChecker — HTTP 状态码 + 响应体匹配
|
||||
│ └── DockerChecker — docker ps 容器状态检查
|
||||
├── AlertManager(@shared/lib/alert/alert-manager)
|
||||
│ ├── evaluate() — 三阶段告警决策(级别匹配 → 免打扰 → 冷却)
|
||||
│ └── CooldownProvider — alert_settings 表持久化冷却状态
|
||||
└── WeChatPusher(@shared/lib/wechat/wechat-pusher)
|
||||
└── pushMarkdown() — 企业微信 Webhook 推送
|
||||
```
|
||||
|
||||
### 运行机制
|
||||
|
||||
| 阶段 | 间隔 | 说明 |
|
||||
|------|------|------|
|
||||
| 健康检查 | 30 秒 tick | 遍历所有 enabled 服务,并发执行检查(Promise.allSettled) |
|
||||
| 抖动检测 | 10 分钟窗口 | 同一服务 10 分钟内状态切换 >= 5 次 → 触发 flapping 告警,抑制后续告警 |
|
||||
| 持续异常提醒 | 30 分钟 | 服务持续 abnormal 状态,每 30 分钟发送提醒 |
|
||||
| 恢复通知 | 即时 | 服务恢复正常时发送 recovery 通知,绕过免打扰和冷却 |
|
||||
| WAL 检查点 | 1 小时 | 被动 WAL checkpoint(PASSIVE) |
|
||||
| 数据清理 | 每天 | 删除 180 天前的 status_history 和 alert_history(每批 LIMIT 1000) |
|
||||
|
||||
### Docker 入口(entrypoint.sh)
|
||||
|
||||
```
|
||||
entrypoint.sh
|
||||
├── npx tsx scripts/monitor-worker.ts & # 后台启动 Worker
|
||||
├── node server.js & # 后台启动 Next.js Server
|
||||
└── 守护循环:任一进程退出则容器退出 # 保证双进程存活
|
||||
└── trap SIGTERM/SIGINT → 清理退出
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 认证机制
|
||||
|
||||
- **OIDC SSO**:通过 Authelia 进行 PKCE 授权码流程,登录成功后签发 `tlyq_session` 共享 JWT cookie
|
||||
- **LDAP 本地登录**:通过 LLDAP 进行 bind 认证,签发 `tlyq_session` cookie
|
||||
- **localadmin**:纯本地 BCrypt 认证,不依赖 LLDAP/OIDC,用于应急登录
|
||||
- **Edge Runtime 中间件**:`src/middleware.ts` 运行在 Edge Runtime,使用 `atob` 解码 JWT payload 检查过期时间,不验证签名(签名验证在 route handler 层)
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 本地与云端差异
|
||||
|
||||
| 环境变量 | 本地开发 | 云服务器(txjp) | 说明 |
|
||||
|---------|---------|----------------|------|
|
||||
| `DATABASE_PATH` | `./data/monitor.db` | `/app/data/monitor.db` | Docker volume 挂载 |
|
||||
| `MONITOR_MODE` | `dev` | `local` | 运行模式 |
|
||||
| `AUTHELIA_URL` | `https://127.0.0.1:6180` | `https://sso.tlyq.ai` | Authelia 地址 |
|
||||
| `OIDC_CLIENT_ID` | `monitor-oidc` | 同 | OIDC 客户端 ID |
|
||||
| `OIDC_CLIENT_SECRET` | 本地生成的值 | 服务器生成的值 | OIDC 客户端密钥(每个环境独立) |
|
||||
| `OIDC_REDIRECT_URI` | `http://localhost:6181/api/auth/callback` | `https://monitor.tlyq.ai/api/auth/callback` | OIDC 回调地址 |
|
||||
| `JWT_SECRET` | `dev-jwt-secret-change-in-production` | 部署脚本自动生成 | JWT 签名密钥 |
|
||||
| `COOKIE_DOMAIN` | `.tlyq.ai` | 同 | Cookie 域 |
|
||||
| `LDAP_URL` | `ldap://ldap-ai:3890` | `ldap://ldap-ai:3890` | LLDAP 地址(Docker 内网) |
|
||||
| `LOCALADMIN_PASSWORD` | `admin123` | 部署脚本自动生成 | 应急管理员密码 |
|
||||
| `NODE_TLS_REJECT_UNAUTHORIZED` | `0` | `0` | TLS 证书验证(Authelia 自签名) |
|
||||
|
||||
### `.env.local` 示例
|
||||
|
||||
```bash
|
||||
DATABASE_PATH=./data/monitor.db
|
||||
MONITOR_MODE=dev
|
||||
AUTHELIA_URL=https://127.0.0.1:6180
|
||||
OIDC_CLIENT_ID=monitor-oidc
|
||||
OIDC_CLIENT_SECRET=<本地生成的值>
|
||||
OIDC_REDIRECT_URI=http://localhost:6181/api/auth/callback
|
||||
JWT_SECRET=dev-jwt-secret-change-in-production
|
||||
COOKIE_DOMAIN=.tlyq.ai
|
||||
LDAP_URL=ldap://ldap-ai:3890
|
||||
LOCALADMIN_PASSWORD=admin123
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 共享库引用
|
||||
|
||||
| 库 | 用途 | 导入路径 |
|
||||
|------|------|------|
|
||||
| `@shared/lib/auth/jwt` | JWT 签名/验证(零依赖,Node crypto) | `import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'` |
|
||||
| `@shared/lib/auth/oidc` | OIDC PKCE 流程 | `import { discoverOidcConfig, buildAuthorizeUrl } from '@shared/lib/auth/oidc'` |
|
||||
| `@shared/lib/auth/ldap` | LDAP 认证 | `import { ldapAuth } from '@shared/lib/auth/ldap'` |
|
||||
| `@shared/lib/auth/middleware` | 路由守卫工厂 | `import { createMiddleware } from '@shared/lib/auth/middleware'` |
|
||||
| `@shared/lib/auth/user-sync` | OIDC 用户同步 | `import { syncOidcUser } from '@shared/lib/auth/user-sync'` |
|
||||
| `@shared/lib/alert/alert-manager` | 告警决策引擎 | `import { AlertManager } from '@shared/lib/alert/alert-manager'` |
|
||||
| `@shared/lib/alert/health-checker` | 健康检查引擎 | `import { HealthChecker, HttpChecker, DockerChecker } from '@shared/lib/alert/health-checker'` |
|
||||
| `@shared/lib/audit/write-audit-log` | 审计日志 | `import { writeAuditLog } from '@shared/lib/audit/write-audit-log'` |
|
||||
| `@shared/lib/wechat/wechat-pusher` | 企业微信推送 | `import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher'` |
|
||||
| `@shared/lib/wechat/message-formatter` | 消息格式化 | `import { formatAlertLevel } from '@shared/lib/wechat/message-formatter'` |
|
||||
| `@shared/lib/db/alert-schema` | 数据库 Schema 常量 | `import { ALL_TABLES_SQL } from '@shared/lib/db/alert-schema'` |
|
||||
| `@shared/ui` | UI 组件库 | `import { Button, Card, Badge } from '@shared/ui'` |
|
||||
| `@shared/ui/login-page` | 统一登录页 | `import LoginPage from '@shared/ui/login-page'` |
|
||||
|
||||
> **路径映射**:`tsconfig.json` 中配置 `"@shared/*": ["./shared/*"]`,`shared` 是指向 `../shared` 的符号链接。
|
||||
|
||||
---
|
||||
|
||||
## 关键设计决策
|
||||
|
||||
### execFileSync 数组参数
|
||||
|
||||
数据库操作使用 `execFileSync('sqlite3', [dbPath, '-json', sql])` 而非 ORM。SQL 通过数组参数传递,避免 shell 注入。所有用户输入通过 `escapeSql()` 函数转义(单引号双写)。
|
||||
|
||||
### Edge Runtime 中间件
|
||||
|
||||
`src/middleware.ts` 运行在 Edge Runtime,不能使用 Node.js `crypto` 模块。JWT 验证仅解码 payload 检查过期时间,签名验证留给 route handler 层处理。
|
||||
|
||||
### 双进程守护
|
||||
|
||||
`entrypoint.sh` 同时启动 Worker 和 Server,使用 bash 守护循环监控。任一进程退出则整个容器退出,由 Docker 重启策略接管。
|
||||
|
||||
### 冷却状态持久化
|
||||
|
||||
告警冷却状态存储在 `alert_settings` 表中(key 格式:`cooldown:{channel_id}:{service_id}:{level}`),容器重启后冷却状态不丢失。
|
||||
|
||||
---
|
||||
|
||||
## Docker 部署
|
||||
|
||||
```
|
||||
txjp 服务器
|
||||
├── monitor-ai(容器) ← Next.js standalone + Worker,监听 6181
|
||||
├── nginx-ai ← 反向代理 monitor.tlyq.ai → monitor-ai:6181
|
||||
└── webnet(external) ← 共享网络
|
||||
```
|
||||
|
||||
部署:`bash deploy-monitor.sh`(本地模式)或 `bash deploy-monitor.sh --remote --host <IP>`。
|
||||
|
||||
源码打包上传 → 服务器 `npm install` + `npm run build` → `.next` 挂载进容器生效。
|
||||
|
||||
### 生产环境变量
|
||||
|
||||
```bash
|
||||
DATABASE_PATH=/app/data/monitor.db
|
||||
MONITOR_MODE=local
|
||||
AUTHELIA_URL=https://sso.tlyq.ai
|
||||
OIDC_CLIENT_ID=monitor-oidc
|
||||
OIDC_CLIENT_SECRET=<部署脚本自动生成>
|
||||
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
|
||||
JWT_SECRET=<部署脚本自动生成>
|
||||
COOKIE_DOMAIN=.tlyq.ai
|
||||
LDAP_URL=ldap://ldap-ai:3890
|
||||
LOCALADMIN_PASSWORD=<部署脚本自动生成>
|
||||
NODE_ENV=production
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发规范
|
||||
|
||||
- **新增 API**:在 `src/app/api/` 下创建路由 → 顶部调用 `initDatabase()` → `getCurrentUser()` 验证 → `checkPermission()` 校验
|
||||
- **新增页面**:在 `src/app/` 下创建 → 布局由 `client-layout.tsx` 提供(Sidebar + ThemeProvider)
|
||||
- **权限格式**:`resource:action`,如 `checkPermission(user, 'services:manage')`
|
||||
- **审计日志**:所有写操作 API(POST/PUT/DELETE)必须添加审计日志:
|
||||
```typescript
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
action: 'create' | 'update' | 'delete',
|
||||
entityType: 'service' | 'alert_channel' | 'user',
|
||||
entityId: id,
|
||||
details: { ... },
|
||||
ipAddress: getClientIP(request)
|
||||
})
|
||||
```
|
||||
- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。必须遵守:
|
||||
1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期,应使用本地时间方法拼接
|
||||
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`
|
||||
3. **INSERT 时间列**:数据库表 DEFAULT 已统一为 `datetime('now', '+8 hours')`,INSERT 时可省略
|
||||
|
||||
---
|
||||
|
||||
## 健康检查兜底
|
||||
|
||||
`scripts/monitor-watchdog.sh` 是独立于 monitor-ai 的兜底脚本,通过 crontab 每 5 分钟执行:
|
||||
|
||||
```bash
|
||||
*/5 * * * * /root/docker/monitor-ai/scripts/monitor-watchdog.sh >> /var/log/monitor-watchdog.log 2>&1
|
||||
```
|
||||
|
||||
当 `/api/health` 连续失败时,直接通过企业微信 Webhook 发送告警,并尝试重启容器。
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
```bash
|
||||
# 容器日志
|
||||
ssh txjp "docker logs monitor-ai"
|
||||
|
||||
# Worker 日志(实时)
|
||||
ssh txjp "docker logs -f monitor-ai 2>&1 | grep -i worker"
|
||||
|
||||
# 数据库查看
|
||||
ssh txjp "docker exec monitor-ai sqlite3 /app/data/monitor.db 'SELECT id, name, current_status FROM services;'"
|
||||
|
||||
# 健康检查
|
||||
ssh txjp "curl -s -k https://monitor.tlyq.ai/api/health"
|
||||
|
||||
# 手动触发检查
|
||||
ssh txjp "docker exec monitor-ai sqlite3 /app/data/monitor.db 'UPDATE services SET current_status=\"unknown\";'"
|
||||
|
||||
# 重建镜像(新增依赖后)
|
||||
ssh txjp "cd /root/docker/monitor-ai && docker compose build --no-cache && docker compose down && docker compose up -d"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Git Tag 规范
|
||||
|
||||
使用日期版本号 `vYYYY.MM.DD`(如 `v2026.07.01`)。提交后打 tag 再推送:
|
||||
|
||||
```bash
|
||||
git tag v$(date +%Y.%m.%d) && git push origin main && git push origin v$(date +%Y.%m.%d)
|
||||
```
|
||||
|
||||
同一天多次提交只打一个 tag。详见根目录 `CLAUDE.md`。
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# monitor-ai Dockerfile
|
||||
FROM node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# 1. 依赖安装
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --production=false
|
||||
|
||||
# 2. 复制共享库
|
||||
COPY ../shared/ ./shared/
|
||||
|
||||
# 3. 复制源码并构建
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 4. 生产镜像
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=base /app/.next/standalone ./
|
||||
COPY --from=base /app/.next/static ./.next/static
|
||||
COPY --from=base /app/scripts ./scripts
|
||||
COPY --from=base /app/shared ./shared
|
||||
COPY entrypoint.sh ./
|
||||
|
||||
RUN npm install -g tsx
|
||||
EXPOSE 6181
|
||||
CMD ["sh", "entrypoint.sh"]
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
version: '3.8'
|
||||
services:
|
||||
monitor-ai:
|
||||
build: .
|
||||
container_name: monitor-ai
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6181:6181"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_PATH=/app/data/monitor.db
|
||||
- MONITOR_MODE=local
|
||||
networks:
|
||||
- webnet
|
||||
init: true
|
||||
|
||||
networks:
|
||||
webnet:
|
||||
external: true
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# LOOP-STATE — monitor-ai Full Review
|
||||
|
||||
## 审查状态 — monitor-ai Phase: 全量审查
|
||||
|
||||
### 第 1 轮
|
||||
- 日期: 2026-07-01
|
||||
- 发现: 2 CRITICAL, 3 HIGH, 3 MEDIUM
|
||||
- 修复状态: 已全部修复
|
||||
|
||||
#### 问题清单
|
||||
| 编号 | 严重度 | 文件:行号 | 描述 | 状态 |
|
||||
|------|--------|-----------|------|------|
|
||||
| C1 | CRITICAL | src/app/api/auth/login/route.ts | localadmin 密码未加密存储,使用明文比较 | ✅ 已修复 |
|
||||
| C2 | CRITICAL | shared/lib/auth/oidc.ts | nonce 未从 ID token 解析验证 | ✅ 已修复 |
|
||||
| H2 | HIGH | src/lib/monitor-worker.ts:88-89 | 日期计算未考虑 UTC+8 时区 | ✅ 已修复 |
|
||||
| H6 | HIGH | src/lib/monitor-worker.ts:144-158 | 字段名映射未处理 SQLite snake_case | ✅ 已修复 |
|
||||
| H7 | HIGH | src/app/api/services/route.ts:40 | 审计日志缺少 userId/username | ✅ 已修复 |
|
||||
| M1 | MEDIUM | src/lib/monitor-worker.ts:35,111 | timestamp 字段未使用 escapeSql | ✅ 已修复 |
|
||||
| M3 | MEDIUM | src/lib/db.ts:45 | 硬编码 webhook URL 未清空 | ✅ 已修复 |
|
||||
|
||||
### 第 2 轮
|
||||
- 日期: 2026-07-01
|
||||
- 发现: 0 CRITICAL, 0 HIGH, 0 MEDIUM, 1 LOW
|
||||
- 结论: **通过** — 连续两轮零 CRITICAL/HIGH 问题
|
||||
|
||||
#### 验证结果
|
||||
| 修复项 | 验证证据 | 状态 |
|
||||
|--------|----------|------|
|
||||
| C1 | route.ts:3 import bcrypt, route.ts:32 bcrypt.compareSync, db.ts:39-41 hashSync | ✅ 通过 |
|
||||
| C2 | oidc.ts:76-82 Buffer.from(..., 'base64url') 解析 nonce | ✅ 通过 |
|
||||
| H2 | monitor-worker.ts:89 `new Date(now.getTime() + 8 * 3600000)` | ✅ 通过 |
|
||||
| H6 | monitor-worker.ts:147-157 所有 snake_case 字段正确映射 | ✅ 通过 |
|
||||
| H7 | services/route.ts:40 userId/username 已添加 | ✅ 通过 |
|
||||
| M1 | monitor-worker.ts:2 import escapeSql, 多处使用 | ✅ 通过 |
|
||||
| M3 | db.ts:45 webhook_url = '' | ✅ 通过 |
|
||||
|
||||
#### 新发现问题
|
||||
| 编号 | 严重度 | 文件:行号 | 描述 | 状态 |
|
||||
|------|--------|-----------|------|------|
|
||||
| LOW-1 | LOW | services/route.ts:40 | payload.sub 类型断言可优化 | 待优化(不影响功能)|
|
||||
|
||||
---
|
||||
|
||||
**审查结论:连续两轮通过,审查结束。**
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
# entrypoint.sh — 启动 Next.js standalone server + Monitor Worker
|
||||
|
||||
echo "[entrypoint] Starting monitor-ai..."
|
||||
|
||||
# 启动 MonitorWorker(后台,tsx 即时执行 TypeScript)
|
||||
npx tsx scripts/monitor-worker.ts &
|
||||
WORKER_PID=$!
|
||||
|
||||
# 启动 Next.js standalone server
|
||||
node server.js &
|
||||
NEXT_PID=$!
|
||||
|
||||
echo "[entrypoint] Worker PID: $WORKER_PID, Next.js PID: $NEXT_PID"
|
||||
|
||||
# 信号处理
|
||||
cleanup() {
|
||||
echo "[entrypoint] Shutting down..."
|
||||
kill $WORKER_PID $NEXT_PID 2>/dev/null
|
||||
wait
|
||||
exit 0
|
||||
}
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# 守护循环:任一进程退出则容器退出
|
||||
while true; do
|
||||
if ! kill -0 $WORKER_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Worker process exited" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! kill -0 $NEXT_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Next.js process exited" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import type { NextConfig } from 'next'
|
||||
|
||||
const config: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['lucide-react'],
|
||||
}
|
||||
|
||||
export default config
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "monitor-ai",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 6181",
|
||||
"build": "next build",
|
||||
"start": "next start -p 6181"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"ldapts": "^6.0.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "^15.0.0",
|
||||
"openid-client": "^5.7.1",
|
||||
"postcss": "^8.5.16",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tsx": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
export default config;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,431 @@
|
|||
#!/usr/bin/env bash
|
||||
# monitor-ai 部署脚本
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy-monitor.sh # 本地模式(默认)
|
||||
# bash deploy-monitor.sh --dev # 开发模式(本地构建)
|
||||
# bash deploy-monitor.sh --remote --host <IP> # 远程部署到指定服务器
|
||||
#
|
||||
# 首次部署自动:
|
||||
# 1. 生成 OIDC_CLIENT_SECRET (openssl rand -hex 32)
|
||||
# 2. 生成 LOCALADMIN_PASSWORD (openssl rand -hex 16)
|
||||
# 3. 生成 pbkdf2 密码哈希(通过 Authelia 容器)
|
||||
# 4. 更新 nginx 配置
|
||||
#
|
||||
# 支持系统:
|
||||
# - macOS (本地): zsh + bash 兼容模式
|
||||
# - Ubuntu/Debian (服务器): dash + bash
|
||||
# - Rocky Linux/CentOS/RHEL (服务器): bash
|
||||
|
||||
# ============================================================
|
||||
# 颜色和日志
|
||||
# ============================================================
|
||||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m'
|
||||
log() { printf "${GREEN}[✓]${NC} %s\n" "$1"; }
|
||||
warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; }
|
||||
info() { printf "${CYAN}[i]${NC} %s\n" "$1"; }
|
||||
error() { printf "${RED}[✗]${NC} %s\n" "$1"; }
|
||||
timing(){ printf " ${CYAN}⏱${NC} %s\n" "$1"; }
|
||||
|
||||
# ============================================================
|
||||
# 检测 macOS 和 Linux sed 差异
|
||||
# ============================================================
|
||||
is_mac() {
|
||||
[[ "$(uname)" == "Darwin" ]]
|
||||
}
|
||||
|
||||
SED_I_BACKUP='-i'
|
||||
if is_mac; then
|
||||
SED_I_BACKUP='-i ""'
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 解析参数
|
||||
# ============================================================
|
||||
MODE="local"
|
||||
HOST=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--remote)
|
||||
MODE="remote"
|
||||
info "模式: 远程部署"
|
||||
shift
|
||||
;;
|
||||
--host)
|
||||
HOST="$2"
|
||||
info "目标主机: $HOST"
|
||||
shift 2
|
||||
;;
|
||||
--dev)
|
||||
MODE="dev"
|
||||
info "模式: 开发构建"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
warn "未知参数: $1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ============================================================
|
||||
# 路径配置
|
||||
# ============================================================
|
||||
LOCAL_DIR="/Users/niuniu/programs/docker"
|
||||
MONITOR_DIR="$LOCAL_DIR/monitor-ai"
|
||||
SHARED_DIR="$LOCAL_DIR/shared"
|
||||
REMOTE_DIR="/root/docker/monitor-ai"
|
||||
CONTAINER="monitor-ai"
|
||||
SSH_TARGET="txjp"
|
||||
SNAPSHOT_FILE="/tmp/.snapshot.monitor.md5"
|
||||
|
||||
# 远程模式需要 host 参数
|
||||
if [[ "$MODE" == "remote" && -z "$HOST" ]]; then
|
||||
error "远程部署需要指定 --host <IP>"
|
||||
echo " 用法: bash deploy-monitor.sh --remote --host 43.133.38.210"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# 打包源码
|
||||
# ============================================================
|
||||
package_source() {
|
||||
log "打包源码..."
|
||||
local pack_start
|
||||
pack_start=$(date +%s)
|
||||
|
||||
COPYFILE_DISABLE=1 tar czf /tmp/monitor-ai.tar.gz \
|
||||
-C "$LOCAL_DIR" \
|
||||
--exclude='node_modules' --exclude='.next' --exclude='out' \
|
||||
--exclude='data' --exclude='docs' \
|
||||
--exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \
|
||||
--exclude='.git' --exclude='._*' \
|
||||
shared/ monitor-ai/ || {
|
||||
error "打包失败"
|
||||
return 1
|
||||
}
|
||||
|
||||
local pack_end pkg_size pack_dur
|
||||
pack_end=$(date +%s)
|
||||
pack_dur=$((pack_end - pack_start))
|
||||
pkg_size=$(du -h /tmp/monitor-ai.tar.gz 2>/dev/null | cut -f1 || echo "未知")
|
||||
timing "打包耗时: ${pack_dur}s (${pkg_size})"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 首次部署:生成密钥和配置
|
||||
# ============================================================
|
||||
first_deploy_setup() {
|
||||
local target="$1" # SSH target or "local"
|
||||
local ssh_cmd=""
|
||||
if [[ "$target" != "local" ]]; then
|
||||
ssh_cmd="ssh $target"
|
||||
fi
|
||||
|
||||
local run
|
||||
if [[ -n "$ssh_cmd" ]]; then
|
||||
run="$ssh_cmd"
|
||||
else
|
||||
run=""
|
||||
fi
|
||||
|
||||
info "首次部署检查..."
|
||||
|
||||
# 检查 .env 是否存在
|
||||
local env_exists
|
||||
if [[ -n "$run" ]]; then
|
||||
env_exists=$($run "test -f $REMOTE_DIR/.env && echo yes || echo no" 2>/dev/null)
|
||||
else
|
||||
env_exists=$(test -f "$MONITOR_DIR/.env" && echo yes || echo no)
|
||||
fi
|
||||
|
||||
if [[ "$env_exists" == "yes" ]]; then
|
||||
info "已存在 .env,跳过密钥生成"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "首次部署:生成密钥和配置..."
|
||||
|
||||
local OIDC_SECRET LOCALADMIN_PASS JWT_SECRET PBKDF2_HASH
|
||||
OIDC_SECRET=$(openssl rand -hex 32)
|
||||
LOCALADMIN_PASS=$(openssl rand -hex 16)
|
||||
JWT_SECRET=$(openssl rand -hex 32)
|
||||
|
||||
# 生成 pbkdf2 密码哈希(通过 Authelia 容器或本地 openssl)
|
||||
if [[ -n "$run" ]]; then
|
||||
PBKDF2_HASH=$($run "docker exec authelia authelia crypto hash generate pbkdf2 --password '$LOCALADMIN_PASS' 2>/dev/null | grep 'Digest:' | awk '{print \$2}'" 2>/dev/null)
|
||||
fi
|
||||
|
||||
# 如果 Authelia 不可用,使用 openssl 生成备用哈希
|
||||
if [[ -z "$PBKDF2_HASH" ]]; then
|
||||
warn "Authelia 不可用,使用 openssl 生成密码哈希"
|
||||
PBKDF2_HASH=$(echo -n "$LOCALADMIN_PASS" | openssl dgst -sha256 -binary | openssl base64)
|
||||
PBKDF2_HASH="pbkdf2:sha256:600000\$$(openssl rand -hex 16)\$$PBKDF2_HASH"
|
||||
fi
|
||||
|
||||
# 写入 .env 文件
|
||||
local env_content
|
||||
env_content="DATABASE_PATH=/app/data/monitor.db
|
||||
MONITOR_MODE=local
|
||||
AUTHELIA_URL=https://sso.tlyq.ai
|
||||
OIDC_CLIENT_ID=monitor-oidc
|
||||
OIDC_CLIENT_SECRET=$OIDC_SECRET
|
||||
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
|
||||
JWT_SECRET=$JWT_SECRET
|
||||
COOKIE_DOMAIN=.tlyq.ai
|
||||
LDAP_URL=ldap://ldap-ai:3890
|
||||
LOCALADMIN_PASSWORD=$LOCALADMIN_PASS
|
||||
NODE_ENV=production
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0"
|
||||
|
||||
if [[ -n "$run" ]]; then
|
||||
$run "cat > $REMOTE_DIR/.env << 'ENVEOF'
|
||||
$env_content
|
||||
ENVEOF"
|
||||
else
|
||||
echo "$env_content" > "$MONITOR_DIR/.env"
|
||||
fi
|
||||
|
||||
log "密钥已生成"
|
||||
info " OIDC_CLIENT_SECRET: $OIDC_SECRET"
|
||||
info " LOCALADMIN_PASSWORD: $LOCALADMIN_PASS"
|
||||
warn "请妥善保管以上密钥,.env 文件不会上传到版本控制"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 更新 nginx 配置
|
||||
# ============================================================
|
||||
update_nginx() {
|
||||
local target="$1"
|
||||
local nginx_conf="$LOCAL_DIR/nginx-proxy-ai/conf.d/monitor-ai.conf"
|
||||
|
||||
if [[ ! -f "$nginx_conf" ]]; then
|
||||
warn "nginx 配置文件不存在: $nginx_conf"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$target" == "local" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "更新 nginx 配置..."
|
||||
scp "$nginx_conf" "$target:$REMOTE_DIR/../nginx-proxy-ai/conf.d/monitor-ai.conf" 2>/dev/null || true
|
||||
ssh "$target" "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" && log "nginx 已重载" || warn "nginx 重载失败"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 服务器构建
|
||||
# ============================================================
|
||||
build_on_server() {
|
||||
local target="$1"
|
||||
|
||||
# 1. 计算源码快照
|
||||
log "检查源码是否有变化..."
|
||||
local local_md5
|
||||
local_md5=$(find "$MONITOR_DIR" "$SHARED_DIR" \
|
||||
-not -path '*/node_modules/*' \
|
||||
-not -path '*/.next/*' \
|
||||
-not -path '*/out/*' \
|
||||
-not -path '*/data/*' \
|
||||
-not -path '*/docs/*' \
|
||||
-not -path '*/.env*' \
|
||||
-not -path '*/.git/*' \
|
||||
-not -name '._*' \
|
||||
-type f \
|
||||
-exec md5 -q {} \; 2>/dev/null \
|
||||
| sort \
|
||||
| md5 -q)
|
||||
echo " 源码快照: ${local_md5}"
|
||||
|
||||
local prev_md5
|
||||
prev_md5=$(ssh "$target" "cat ${SNAPSHOT_FILE} 2>/dev/null" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "$prev_md5" ]]; then
|
||||
info "首次部署,执行完整构建"
|
||||
elif [[ "$prev_md5" == "$local_md5" ]]; then
|
||||
info "源码无变化,跳过构建,仅重启容器"
|
||||
ssh "$target" "cd $REMOTE_DIR && docker compose down && docker compose up -d"
|
||||
log "容器已重建"
|
||||
return 0
|
||||
else
|
||||
info "检测到源码变化,执行增量构建"
|
||||
fi
|
||||
|
||||
# 2. 打包源码
|
||||
package_source
|
||||
|
||||
# 3. 上传
|
||||
log "上传源码包..."
|
||||
scp /tmp/monitor-ai.tar.gz "$target:/tmp/monitor-ai.tar.gz" || {
|
||||
error "上传失败"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 4. 解压源码到 /tmp,然后 rsync 到目标目录
|
||||
log "解压源码并准备依赖..."
|
||||
ssh "$target" "\
|
||||
rm -rf /tmp/deploy_monitor && mkdir -p /tmp/deploy_monitor && \
|
||||
tar xzf /tmp/monitor-ai.tar.gz -C /tmp/deploy_monitor 2>/dev/null || true && \
|
||||
rsync -a --delete \
|
||||
--exclude='node_modules' --exclude='.next' --exclude='data' --exclude='docs' \
|
||||
--exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \
|
||||
--exclude='._*' \
|
||||
/tmp/deploy_monitor/monitor-ai/ $REMOTE_DIR/ && \
|
||||
rsync -a --delete \
|
||||
--exclude='node_modules' --exclude='.git' \
|
||||
/tmp/deploy_monitor/shared/ /root/docker/shared/ && \
|
||||
rm -rf /tmp/deploy_monitor"
|
||||
|
||||
# 5. 首次部署:上传 docker-compose.yml 和 Dockerfile
|
||||
ssh "$target" "cd $REMOTE_DIR && docker compose up -d"
|
||||
|
||||
# 如果 host 上没有 node_modules,从容器内复制一份
|
||||
ssh "$target" "if [ ! -d '${REMOTE_DIR}/node_modules' ]; then
|
||||
echo ' 首次:复制容器内 node_modules 到主机...'
|
||||
docker cp ${CONTAINER}:/app/node_modules ${REMOTE_DIR}/node_modules
|
||||
echo ' 完成'
|
||||
fi"
|
||||
|
||||
# 6. 安装可能新增的依赖
|
||||
log "安装新增依赖..."
|
||||
ssh "$target" "cd ${REMOTE_DIR} && npm install --prefer-offline 2>&1 | tail -5 || true"
|
||||
|
||||
# 7. 清理可能被上传的本地环境变量文件
|
||||
ssh "$target" "rm -f $REMOTE_DIR/.env.local $REMOTE_DIR/.env.development $REMOTE_DIR/.env.production 2>/dev/null; echo '已清理本地环境文件'"
|
||||
|
||||
# 8. 服务器上执行 npm run build
|
||||
log "服务器上执行 npm run build..."
|
||||
local build_start build_end build_dur
|
||||
build_start=$(date +%s)
|
||||
ssh "$target" "cd ${REMOTE_DIR} && npm run build 2>&1" | \
|
||||
grep -vE "^(info|warn|npm warn|audited|packages|funding|vulnerability|npm notice|New major)" | \
|
||||
tail -15
|
||||
local build_exit_code=${PIPESTATUS[0]}
|
||||
build_end=$(date +%s)
|
||||
build_dur=$((build_end - build_start))
|
||||
timing "构建耗时: ${build_dur}s"
|
||||
|
||||
if [[ "$build_exit_code" -ne 0 ]]; then
|
||||
warn "构建可能有警告,请检查上面的输出"
|
||||
fi
|
||||
|
||||
# 9. 重建容器
|
||||
log "重建容器..."
|
||||
ssh "$target" "cd $REMOTE_DIR && docker compose up -d && docker compose restart"
|
||||
|
||||
# 10. 清理构建缓存
|
||||
log "清理构建缓存..."
|
||||
ssh "$target" "docker image prune -f 2>/dev/null || true"
|
||||
|
||||
local disk_usage
|
||||
disk_usage=$(ssh "$target" "df / --output=pcent | tail -1 | tr -d ' %'" 2>/dev/null || echo "50")
|
||||
if [ "$disk_usage" -gt 95 ]; then
|
||||
warn "磁盘使用率 ${disk_usage}%,执行紧急清理"
|
||||
ssh "$target" "docker builder prune -f 2>/dev/null || true"
|
||||
elif [ "$disk_usage" -gt 85 ]; then
|
||||
info "磁盘使用率 ${disk_usage}%,执行激进清理(6小时)"
|
||||
ssh "$target" "docker builder prune -f --filter 'until=6h' 2>/dev/null || true"
|
||||
elif [ "$disk_usage" -gt 70 ]; then
|
||||
info "磁盘使用率 ${disk_usage}%,执行正常清理(24小时)"
|
||||
ssh "$target" "docker builder prune -f --filter 'until=24h' 2>/dev/null || true"
|
||||
else
|
||||
info "磁盘使用率 ${disk_usage}%,执行保守清理(48小时)"
|
||||
ssh "$target" "docker builder prune -f --filter 'until=48h' 2>/dev/null || true"
|
||||
fi
|
||||
|
||||
# 11. 保存快照
|
||||
ssh "$target" "echo '$local_md5' > ${SNAPSHOT_FILE}"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 开发模式(本地构建)
|
||||
# ============================================================
|
||||
build_dev() {
|
||||
log "本地开发模式构建..."
|
||||
local build_start build_end build_dur
|
||||
build_start=$(date +%s)
|
||||
|
||||
cd "$MONITOR_DIR" || { error "无法进入 $MONITOR_DIR"; exit 1; }
|
||||
npm run build 2>&1 | tail -15
|
||||
local build_exit_code=${PIPESTATUS[0]}
|
||||
build_end=$(date +%s)
|
||||
build_dur=$((build_end - build_start))
|
||||
timing "构建耗时: ${build_dur}s"
|
||||
|
||||
if [[ "$build_exit_code" -ne 0 ]]; then
|
||||
error "构建失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "本地构建完成"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 执行部署
|
||||
# ============================================================
|
||||
echo ""
|
||||
printf "${CYAN}=========================================${NC}\n"
|
||||
printf "${CYAN} monitor-ai 部署${NC}\n"
|
||||
printf "${CYAN}=========================================${NC}\n"
|
||||
echo ""
|
||||
|
||||
BUILD_START=$(date +%s)
|
||||
|
||||
case "$MODE" in
|
||||
local)
|
||||
# 本地模式:部署到 txjp 服务器(默认行为)
|
||||
log "部署目标: $MONITOR_DIR → txjp:$REMOTE_DIR"
|
||||
echo ""
|
||||
|
||||
# 首次部署检查
|
||||
first_deploy_setup "$SSH_TARGET"
|
||||
|
||||
# 构建
|
||||
build_on_server "$SSH_TARGET"
|
||||
|
||||
# 更新 nginx
|
||||
update_nginx "$SSH_TARGET"
|
||||
;;
|
||||
|
||||
remote)
|
||||
# 远程模式:部署到指定服务器
|
||||
log "部署目标: $MONITOR_DIR → $HOST:$REMOTE_DIR"
|
||||
echo ""
|
||||
|
||||
# 首次部署检查
|
||||
first_deploy_setup "$HOST"
|
||||
|
||||
# 构建
|
||||
build_on_server "$HOST"
|
||||
|
||||
# 更新 nginx
|
||||
update_nginx "$HOST"
|
||||
;;
|
||||
|
||||
dev)
|
||||
# 开发模式:仅本地构建
|
||||
build_dev
|
||||
;;
|
||||
esac
|
||||
|
||||
# ============================================================
|
||||
# 验证
|
||||
# ============================================================
|
||||
echo ""
|
||||
log "验证部署..."
|
||||
BUILD_END=$(date +%s)
|
||||
TOTAL_DUR=$((BUILD_END - BUILD_START))
|
||||
|
||||
if [[ "$MODE" == "dev" ]]; then
|
||||
log "开发构建完成,总耗时: ${TOTAL_DUR}s"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
STATUS=$(ssh "$SSH_TARGET" "curl -s -o /dev/null -w '%{http_code}' -k 'https://monitor.tlyq.ai/api/health' 2>/dev/null" 2>/dev/null || echo "???")
|
||||
if [[ "$STATUS" == "200" ]]; then
|
||||
log "部署成功!总耗时: ${TOTAL_DUR}s | 访问 https://monitor.tlyq.ai"
|
||||
exit 0
|
||||
else
|
||||
warn "返回状态码: $STATUS,请检查"
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
#!/usr/bin/env bash
|
||||
# monitor-ai 健康检查兜底脚本
|
||||
#
|
||||
# 用途:当 monitor-ai 自身的健康检查失败时,通过 webhook 直接发送告警
|
||||
# 部署:添加到 crontab,每 5 分钟执行一次
|
||||
#
|
||||
# crontab 配置:
|
||||
# */5 * * * * /root/docker/monitor-ai/scripts/monitor-watchdog.sh >> /var/log/monitor-watchdog.log 2>&1
|
||||
#
|
||||
# 日志文件:/var/log/monitor-watchdog.log(建议配合 logrotate)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================
|
||||
# 配置
|
||||
# ============================================================
|
||||
HEALTH_URL="${MONITOR_HEALTH_URL:-https://monitor.tlyq.ai/api/health}"
|
||||
TIMEOUT=10
|
||||
MAX_RETRIES=2
|
||||
RETRY_DELAY=5
|
||||
|
||||
# 企业微信 Webhook(从 .env 读取或使用环境变量)
|
||||
MONITOR_DIR="/root/docker/monitor-ai"
|
||||
if [[ -f "$MONITOR_DIR/.env" ]]; then
|
||||
# 从 .env 读取第一个启用的 webhook URL
|
||||
WEBHOOK_URL="${WEBHOOK_URL:-$(grep -E '^WEBHOOK_URL=' "$MONITOR_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2-)}"
|
||||
fi
|
||||
|
||||
# 如果没有配置 webhook,尝试从数据库读取
|
||||
if [[ -z "${WEBHOOK_URL:-}" ]]; then
|
||||
DB_PATH="${MONITOR_DIR}/data/monitor.db"
|
||||
if [[ -f "$DB_PATH" ]]; then
|
||||
WEBHOOK_URL=$(sqlite3 "$DB_PATH" "SELECT webhook_url FROM alert_channels WHERE enabled=1 LIMIT 1;" 2>/dev/null || echo "")
|
||||
fi
|
||||
fi
|
||||
|
||||
# 日志函数
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 健康检查
|
||||
# ============================================================
|
||||
check_health() {
|
||||
local attempt=0
|
||||
local http_code=""
|
||||
|
||||
while [[ $attempt -lt $MAX_RETRIES ]]; do
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
http_code=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
--connect-timeout "$TIMEOUT" \
|
||||
--max-time "$TIMEOUT" \
|
||||
"$HEALTH_URL" 2>/dev/null) || http_code="000"
|
||||
|
||||
if [[ "$http_code" == "200" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ $attempt -lt $MAX_RETRIES ]]; then
|
||||
log "健康检查失败 (HTTP $http_code),${RETRY_DELAY}s 后重试..."
|
||||
sleep "$RETRY_DELAY"
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 发送告警
|
||||
# ============================================================
|
||||
send_alert() {
|
||||
local message="$1"
|
||||
|
||||
if [[ -z "${WEBHOOK_URL:-}" ]]; then
|
||||
log "WARNING: 未配置企业微信 Webhook URL,无法发送告警"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local payload
|
||||
payload=$(cat <<EOF
|
||||
{
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": "## ⚠️ monitor-ai 自身异常\n\n> 告警时间: $(date '+%Y-%m-%d %H:%M:%S')\n\n**健康检查地址**: ${HEALTH_URL}\n\n**失败原因**: ${message}\n\n**处理建议**: 请检查 monitor-ai 容器状态\n\n<font color=\"warning\">此告警由 monitor-watchdog 发送</font>"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
local response
|
||||
response=$(curl -s -X POST \
|
||||
--connect-timeout 10 \
|
||||
--max-time 10 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$WEBHOOK_URL" 2>&1)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
log "告警已发送: $response"
|
||||
else
|
||||
log "WARNING: 告警发送失败: $response"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# 主逻辑
|
||||
# ============================================================
|
||||
main() {
|
||||
log "开始健康检查: $HEALTH_URL"
|
||||
|
||||
if check_health; then
|
||||
log "健康检查通过 (HTTP 200)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "ERROR: 健康检查失败,尝试发送告警..."
|
||||
send_alert "HTTP 健康检查失败,连续 ${MAX_RETRIES} 次请求均未返回 200"
|
||||
|
||||
# 检查容器状态
|
||||
local container_status
|
||||
container_status=$(docker inspect -f '{{.State.Status}}' monitor-ai 2>/dev/null || echo "not_found")
|
||||
log "容器状态: $container_status"
|
||||
|
||||
if [[ "$container_status" != "running" ]]; then
|
||||
log "ERROR: 容器未运行,尝试重启..."
|
||||
cd "$MONITOR_DIR" && docker compose up -d 2>&1 | while read -r line; do log " $line"; done
|
||||
|
||||
# 等待容器启动后再次检查
|
||||
sleep 10
|
||||
if check_health; then
|
||||
log "容器已重启并通过健康检查"
|
||||
else
|
||||
log "ERROR: 重启后健康检查仍然失败,请人工介入"
|
||||
send_alert "容器已重启,但健康检查仍然失败,请人工介入"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// scripts/monitor-worker.ts — MonitorWorker 独立入口
|
||||
import { initDatabase } from '../src/lib/db'
|
||||
import { MonitorWorker } from '../src/lib/monitor-worker'
|
||||
import { config } from '../src/lib/config'
|
||||
|
||||
console.log('[worker] Starting MonitorWorker...')
|
||||
console.log('[worker] Mode:', config.monitorMode)
|
||||
|
||||
initDatabase()
|
||||
|
||||
const worker = new MonitorWorker()
|
||||
worker.start()
|
||||
|
||||
process.on('SIGTERM', () => { console.log('[worker] SIGTERM, exiting'); process.exit(0) })
|
||||
process.on('SIGINT', () => { console.log('[worker] SIGINT, exiting'); process.exit(0) })
|
||||
|
||||
console.log('[worker] Started. Ticking every 30s.')
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// src/app/admin/audit-logs/page.tsx — 审计日志(占位)
|
||||
export default function AuditLogsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">审计日志</h1>
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-400">审计日志页面</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// src/app/admin/roles/page.tsx — 角色权限管理(占位)
|
||||
export default function RolesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">角色权限管理</h1>
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-400">角色权限管理页面</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// src/app/admin/users/page.tsx — 用户管理(占位,Phase 3 完成)
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">用户管理</h1>
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center">
|
||||
<p className="text-slate-400">用户管理页面(本地测试通过后完善 UI)</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
'use client'
|
||||
// src/app/alerts/page.tsx — 告警历史
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Activity } from 'lucide-react'
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [alerts, setAlerts] = useState<Array<Record<string,unknown>>>([])
|
||||
useEffect(() => { fetch('/api/alerts').then(r => r.json()).then(d => setAlerts(d.alerts || d)) }, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">告警历史</h1>
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 dark:bg-slate-800">
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">时间</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">级别</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">类型</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">标题</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alerts.map((a: Record<string,unknown>) => (
|
||||
<tr key={String(a.id)} className="border-b border-slate-100 dark:border-slate-800">
|
||||
<td className="px-4 py-3 text-slate-500">{String(a.sent_at).slice(0,19)}</td>
|
||||
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded-full text-xs font-medium ${a.level === 'critical' ? 'bg-red-100 text-red-700' : a.level === 'warning' ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'}`}>{String(a.level)}</span></td>
|
||||
<td className="px-4 py-3 text-slate-500">{String(a.alert_type)}</td>
|
||||
<td className="px-4 py-3 font-medium">{String(a.title)}</td>
|
||||
<td className="px-4 py-3">{a.success ? '✅' : '❌'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{alerts.length === 0 && <p className="p-12 text-center text-slate-400">暂无告警记录</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
// GET /api/admin/worker-status — Worker 运行状态
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'dashboard:view')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
return NextResponse.json({ status: 'running', uptime: process.uptime() })
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// PUT/DELETE /api/alert-channels/[id] — 编辑/删除告警渠道
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
async function checkAdmin(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return null
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const admin = await checkAdmin(request)
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const updates: string[] = []
|
||||
|
||||
if (body.name !== undefined) updates.push(`name = ${escapeSql(body.name)}`)
|
||||
if (body.webhook_url !== undefined) updates.push(`webhook_url = ${escapeSql(body.webhook_url)}`)
|
||||
if (body.channel_type !== undefined) updates.push(`channel_type = ${escapeSql(body.channel_type)}`)
|
||||
if (body.level_critical !== undefined) updates.push(`level_critical = ${body.level_critical ? 1 : 0}`)
|
||||
if (body.level_warning !== undefined) updates.push(`level_warning = ${body.level_warning ? 1 : 0}`)
|
||||
if (body.level_info !== undefined) updates.push(`level_info = ${body.level_info ? 1 : 0}`)
|
||||
if (body.enabled !== undefined) updates.push(`enabled = ${body.enabled ? 1 : 0}`)
|
||||
if (body.cooldown_minutes !== undefined) updates.push(`cooldown_minutes = ${Number(body.cooldown_minutes)}`)
|
||||
if (body.quiet_start !== undefined) updates.push(`quiet_start = ${escapeSql(body.quiet_start)}`)
|
||||
if (body.quiet_end !== undefined) updates.push(`quiet_end = ${escapeSql(body.quiet_end)}`)
|
||||
updates.push(`updated_at = datetime('now', '+8 hours')`)
|
||||
|
||||
dbExec(`UPDATE alert_channels SET ${updates.join(', ')} WHERE id = ${Number(id)}`)
|
||||
writeAuditLog({ action: 'update_channel', entityType: 'alert_channel', entityId: Number(id), details: body, ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1' })
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const admin = await checkAdmin(request)
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
dbExec(`DELETE FROM alert_channels WHERE id = ${Number(id)}`)
|
||||
writeAuditLog({ action: 'delete_channel', entityType: 'alert_channel', entityId: Number(id), ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1' })
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// POST /api/alert-channels/[id]/test — 测试推送
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery } from '@/lib/db'
|
||||
import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const { id } = await params
|
||||
const channels = dbQuery<{ webhook_url: string }>(`SELECT webhook_url FROM alert_channels WHERE id = ${Number(id)}`)
|
||||
if (channels.length === 0) return NextResponse.json({ error: 'Channel not found' }, { status: 404 })
|
||||
|
||||
const pusher = new WeChatPusher(String(channels[0].webhook_url))
|
||||
const result = await pusher.pushMarkdown('测试告警', `monitor-ai 告警测试消息\n时间: ${new Date().toLocaleString('zh-CN')}`)
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// GET/POST /api/alert-channels — 告警渠道列表/添加
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const channels = dbQuery('SELECT id, name, channel_type, webhook_url, enabled, level_critical, level_warning, level_info, quiet_enabled, quiet_start, quiet_end, quiet_bypass_critical, cooldown_minutes, created_at, updated_at FROM alert_channels ORDER BY id')
|
||||
return NextResponse.json(channels)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const body = await request.json()
|
||||
dbExec(`INSERT INTO alert_channels (name, webhook_url, channel_type, level_critical, level_warning, level_info, enabled, quiet_start, quiet_end, cooldown_minutes)
|
||||
VALUES (${escapeSql(body.name)}, ${escapeSql(body.webhook_url)}, ${escapeSql(body.channel_type || 'wecom')}, ${body.level_critical ? 1 : 0}, ${body.level_warning ? 1 : 0}, ${body.level_info ? 1 : 0}, ${body.enabled !== 0 ? 1 : 0}, ${escapeSql(body.quiet_start || null)}, ${escapeSql(body.quiet_end || null)}, ${body.cooldown_minutes || 30})`)
|
||||
writeAuditLog({ action: 'create_channel', entityType: 'alert_channel', details: { name: body.name }, ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1' })
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// GET /api/alerts — 告警历史列表(admin / editor)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery } from '@/lib/db'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = Number(searchParams.get('page')) || 1
|
||||
const limit = 20
|
||||
const offset = (page - 1) * limit
|
||||
|
||||
const alerts = dbQuery(`SELECT * FROM alert_history ORDER BY sent_at DESC LIMIT ${limit} OFFSET ${offset}`)
|
||||
const total = dbQuery<{ count: number }>('SELECT COUNT(*) as count FROM alert_history')
|
||||
return NextResponse.json({ alerts, total: total[0]?.count || 0, page })
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
// GET /api/auth/callback — OIDC callback 处理
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { exchangeCodeForToken, getUserinfo } from '@shared/lib/auth/oidc'
|
||||
import { signJwt } from '@shared/lib/auth/jwt'
|
||||
import { syncOidcUser } from '@shared/lib/auth/user-sync'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
|
||||
if (error) {
|
||||
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error)}`, request.url))
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(new URL('/login?error=missing_params', request.url))
|
||||
}
|
||||
|
||||
// 验证 state
|
||||
const savedState = request.cookies.get('oidc_state')?.value
|
||||
if (!savedState || savedState !== state) {
|
||||
return NextResponse.redirect(new URL('/login?error=state_mismatch', request.url))
|
||||
}
|
||||
|
||||
// 取出 code_verifier
|
||||
const codeVerifier = request.cookies.get('oidc_code_verifier')?.value
|
||||
if (!codeVerifier) {
|
||||
return NextResponse.redirect(new URL('/login?error=missing_verifier', request.url))
|
||||
}
|
||||
|
||||
// 换取 token
|
||||
const tokenResult = await exchangeCodeForToken(
|
||||
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
|
||||
code, codeVerifier,
|
||||
)
|
||||
|
||||
if (!tokenResult.success) {
|
||||
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(tokenResult.error || 'token_exchange_failed')}`, request.url))
|
||||
}
|
||||
|
||||
// 验证 nonce
|
||||
const savedNonce = request.cookies.get('oidc_nonce')?.value
|
||||
if (savedNonce && tokenResult.nonce && savedNonce !== tokenResult.nonce) {
|
||||
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', request.url))
|
||||
}
|
||||
|
||||
// 获取 userinfo
|
||||
const userinfo = await getUserinfo(authConfig.autheliaUrl, tokenResult.accessToken)
|
||||
|
||||
// 用户同步
|
||||
const user = syncOidcUser({
|
||||
getUser: (username) => {
|
||||
const rows = dbQuery<{ id: number; role: string }>(`SELECT id, role FROM users WHERE username = ${escapeSql(username)}`)
|
||||
return rows[0] ?? null
|
||||
},
|
||||
createUser: (username, displayName, email) => {
|
||||
dbExec(`INSERT INTO users (username, display_name, email, role) VALUES (${escapeSql(username)}, ${escapeSql(displayName)}, ${escapeSql(email)}, 'viewer')`)
|
||||
const row = dbQuery<{ id: number }>(`SELECT last_insert_rowid() AS id`)
|
||||
return { id: row[0]?.id ?? 0, role: 'viewer' }
|
||||
},
|
||||
updateUser: (username, displayName, email) => {
|
||||
dbExec(`UPDATE users SET display_name = ${escapeSql(displayName)}, email = ${escapeSql(email)}, updated_at = datetime('now', '+8 hours') WHERE username = ${escapeSql(username)}`)
|
||||
},
|
||||
}, userinfo)
|
||||
|
||||
// 签发 JWT
|
||||
const token = signJwt({
|
||||
secret: authConfig.jwtSecret,
|
||||
payload: { username: userinfo.preferred_username, displayName: userinfo.name, role: user.role },
|
||||
})
|
||||
|
||||
const response = NextResponse.redirect(new URL('/', request.url))
|
||||
|
||||
// 签发 tlyq_session cookie
|
||||
response.cookies.set('tlyq_session', token, {
|
||||
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: process.env.NODE_ENV === 'production' ? authConfig.cookieDomain : undefined, path: '/', maxAge: 604800,
|
||||
})
|
||||
|
||||
// 清理 OIDC 临时 cookie
|
||||
response.cookies.delete('oidc_code_verifier')
|
||||
response.cookies.delete('oidc_state')
|
||||
response.cookies.delete('oidc_nonce')
|
||||
|
||||
// 审计日志
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
userId: user.id, username: userinfo.preferred_username, action: 'login',
|
||||
entityType: 'auth', details: { method: 'oidc', isNew: user.isNew },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// GET /api/auth/login/oidc — OIDC SSO 重定向
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generatePkce, generateState, buildAuthorizeUrl } from '@shared/lib/auth/oidc'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
|
||||
export async function GET() {
|
||||
const { codeVerifier, codeChallenge } = generatePkce()
|
||||
const state = generateState()
|
||||
const nonce = generateState()
|
||||
|
||||
const url = buildAuthorizeUrl(
|
||||
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
|
||||
{ codeChallenge, state, nonce },
|
||||
)
|
||||
|
||||
const response = NextResponse.redirect(url)
|
||||
|
||||
// 存储 PKCE 参数到 httpOnly cookie(5 分钟过期)
|
||||
const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 300, path: '/' }
|
||||
response.cookies.set('oidc_code_verifier', codeVerifier, cookieOpts)
|
||||
response.cookies.set('oidc_state', state, cookieOpts)
|
||||
response.cookies.set('oidc_nonce', nonce, cookieOpts)
|
||||
|
||||
return response
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
// POST /api/auth/login — LDAP 本地登录(回退通道)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { signJwt } from '@shared/lib/auth/jwt'
|
||||
import { ldapAuth } from '@shared/lib/auth/ldap'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let username: string, password: string
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
if (contentType.includes('application/json')) {
|
||||
const body = await request.json()
|
||||
username = body.username; password = body.password
|
||||
} else {
|
||||
const form = await request.formData()
|
||||
username = String(form.get('username') || ''); password = String(form.get('password') || '')
|
||||
}
|
||||
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: '用户名和密码不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
// localadmin 密码验证(查询数据库存储的密码)
|
||||
if (username === 'localadmin') {
|
||||
const users = dbQuery<{ password_hash: string; role: string }>(`SELECT password_hash, role FROM users WHERE username = 'localadmin'`)
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({ error: 'localadmin 未配置' }, { status: 401 })
|
||||
}
|
||||
// 与数据库存储的 bcrypt 哈希比较
|
||||
if (!bcrypt.compareSync(password, users[0].password_hash)) {
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
username, action: 'login_failed', entityType: 'auth',
|
||||
details: { method: 'localadmin', reason: 'wrong_password' },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
return NextResponse.json({ error: '密码错误' }, { status: 401 })
|
||||
}
|
||||
|
||||
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username: 'localadmin', role: 'admin', displayName: 'localadmin' } })
|
||||
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
username, action: 'login', entityType: 'auth',
|
||||
details: { method: 'localadmin' },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
|
||||
const response = NextResponse.json({
|
||||
user: { username: 'localadmin', role: 'admin', displayName: 'localadmin' },
|
||||
})
|
||||
response.cookies.set('tlyq_session', token, {
|
||||
httpOnly: true, secure: false, sameSite: 'lax', path: '/', maxAge: 604800,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
// LLDAP 认证
|
||||
const result = await ldapAuth(
|
||||
{ url: authConfig.ldapUrl || 'ldap://ldap-ai:3890', baseDn: 'dc=tlyq,dc=ai' },
|
||||
username, password,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
username, action: 'login_failed', entityType: 'auth',
|
||||
details: { method: 'ldap', reason: result.error },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||
}
|
||||
|
||||
// 签发 JWT
|
||||
const users = dbQuery(`SELECT role FROM users WHERE username = ${escapeSql(username)}`)
|
||||
const role = users.length > 0 ? users[0].role as string : 'viewer'
|
||||
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
username, action: 'login', entityType: 'auth',
|
||||
details: { method: 'ldap', role },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
|
||||
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username, role, displayName: result.displayName || username } })
|
||||
const response = NextResponse.json({
|
||||
user: { username, role, displayName: result.displayName || username },
|
||||
})
|
||||
response.cookies.set('tlyq_session', token, {
|
||||
httpOnly: true, secure: false, sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 604800,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// POST /api/auth/logout — 退出登录
|
||||
import { NextResponse } from 'next/server'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
|
||||
export async function POST() {
|
||||
// 清除 tlyq_session cookie
|
||||
const logoutUrl = `${authConfig.autheliaUrl}/api/oidc/end_session`
|
||||
const response = NextResponse.redirect(logoutUrl)
|
||||
response.cookies.set('tlyq_session', '', { httpOnly: true, secure: false, sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 0 })
|
||||
return response
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
// GET /api/auth/me — 当前用户信息
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
username: payload.username,
|
||||
display_name: payload.displayName || payload.username,
|
||||
role: payload.role || 'viewer',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// GET /api/health — monitor-ai 自身健康检查
|
||||
export async function GET() {
|
||||
return Response.json({ status: 'OK', timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// POST /api/services/[id]/check — 手动触发单次检查
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery } from '@/lib/db'
|
||||
import { HttpChecker, DockerChecker, HealthChecker } from '@shared/lib/alert/health-checker'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!hasPermission(String(payload.role || 'viewer'), 'services:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const services = dbQuery(`SELECT * FROM services WHERE id = ${Number(id)} AND enabled = 1`)
|
||||
if (services.length === 0) return NextResponse.json({ error: 'Service not found' }, { status: 404 })
|
||||
|
||||
const svc = services[0]
|
||||
const checks = JSON.parse(String(svc.checks || '[]'))
|
||||
|
||||
const checker = new HealthChecker()
|
||||
checker.registerChecker('http', new HttpChecker())
|
||||
checker.registerChecker('docker', new DockerChecker())
|
||||
|
||||
const results = await checker.check(checks, Number(svc.check_timeout) || 10)
|
||||
return NextResponse.json({ serviceId: svc.id, results })
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// PUT/DELETE /api/services/[id] — 编辑/删除服务
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
async function checkAdmin(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return null
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload) return null
|
||||
if (!hasPermission(String(payload.role || 'viewer'), 'services:manage')) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const admin = await checkAdmin(request)
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const updates: string[] = []
|
||||
|
||||
if (body.name !== undefined) updates.push(`name = ${escapeSql(body.name)}`)
|
||||
if (body.category !== undefined) updates.push(`category = ${escapeSql(body.category)}`)
|
||||
if (body.alertLevel !== undefined) updates.push(`alert_level = ${escapeSql(body.alertLevel)}`)
|
||||
if (body.checks !== undefined) updates.push(`checks = ${escapeSql(JSON.stringify(body.checks))}`)
|
||||
if (body.checkInterval !== undefined) updates.push(`check_interval = ${Number(body.checkInterval)}`)
|
||||
if (body.checkTimeout !== undefined) updates.push(`check_timeout = ${Number(body.checkTimeout)}`)
|
||||
if (body.enabled !== undefined) updates.push(`enabled = ${body.enabled ? 1 : 0}`)
|
||||
updates.push(`updated_at = datetime('now', '+8 hours')`)
|
||||
|
||||
if (updates.length > 0) {
|
||||
dbExec(`UPDATE services SET ${updates.join(', ')} WHERE id = ${Number(id)}`)
|
||||
}
|
||||
|
||||
writeAuditLog({ action: 'update_service', entityType: 'service', entityId: Number(id), details: body, ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1' })
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const admin = await checkAdmin(request)
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { id } = await params
|
||||
dbExec(`DELETE FROM services WHERE id = ${Number(id)}`)
|
||||
writeAuditLog({ action: 'delete_service', entityType: 'service', entityId: Number(id), ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1' })
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// GET /api/services — 服务列表 + 当前状态
|
||||
// POST /api/services — 添加服务(admin)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export async function GET() {
|
||||
const services = dbQuery(`SELECT * FROM services WHERE enabled = 1 ORDER BY display_order, id`)
|
||||
// 解析 JSON checks 字段
|
||||
const result = services.map(s => ({ ...s, checks: JSON.parse(String(s.checks || '[]')) }))
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!hasPermission(String(payload.role || 'viewer'), 'services:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name, category, alertLevel, checks, checkInterval, checkTimeout } = body
|
||||
|
||||
if (!name) return NextResponse.json({ error: 'Name required' }, { status: 400 })
|
||||
|
||||
const checksJson = escapeSql(JSON.stringify(checks || []))
|
||||
const level = escapeSql(alertLevel || 'warning')
|
||||
const interval = Number(checkInterval) || 30
|
||||
const timeout = Number(checkTimeout) || 10
|
||||
|
||||
dbExec(`INSERT INTO services (name, category, alert_level, checks, check_interval, check_timeout, display_order)
|
||||
VALUES (${escapeSql(name)}, ${escapeSql(category || 'endpoint')}, ${level}, ${checksJson}, ${interval}, ${timeout},
|
||||
(SELECT COALESCE(MAX(display_order), 0) + 1 FROM services))`)
|
||||
|
||||
writeAuditLog({ userId: payload ? Number(payload.sub) || null : null, username: payload?.username as string || null, action: 'create_service', entityType: 'service', details: { name }, ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1' })
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// GET /api/status-history — 状态变更列表
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { dbQuery } from '@/lib/db'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = Number(searchParams.get('page')) || 1
|
||||
const limit = 20
|
||||
const offset = (page - 1) * limit
|
||||
const serviceId = searchParams.get('service_id')
|
||||
|
||||
let where = ''
|
||||
if (serviceId) where = ` WHERE service_id = ${Number(serviceId)}`
|
||||
|
||||
const events = dbQuery(`SELECT sh.*, s.name as service_name FROM status_history sh JOIN services s ON sh.service_id = s.id ${where} ORDER BY sh.started_at DESC LIMIT ${limit} OFFSET ${offset}`)
|
||||
return NextResponse.json(events)
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// GET /api/status — 所有服务实时状态摘要
|
||||
import { NextResponse } from 'next/server'
|
||||
import { dbQuery } from '@/lib/db'
|
||||
|
||||
export async function GET() {
|
||||
const services = dbQuery('SELECT id, name, category, alert_level, current_status, status_since FROM services WHERE enabled = 1 ORDER BY display_order, id')
|
||||
const counts = { normal: 0, abnormal: 0, unknown: 0 }
|
||||
services.forEach((s: Record<string, unknown>) => { const st = String(s.current_status); if (st === 'normal') counts.normal++; else if (st === 'abnormal') counts.abnormal++; else counts.unknown++ })
|
||||
return NextResponse.json({ services, counts, timestamp: new Date().toISOString() })
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
'use client'
|
||||
// src/app/client-layout.tsx — 客户端布局(登录页不显示 Sidebar/TopBar)
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Sidebar from '@/components/Sidebar'
|
||||
import ThemeProvider from '@/components/ThemeProvider'
|
||||
import TopBar from '@/components/TopBar'
|
||||
|
||||
interface User { username: string; display_name: string; role: string }
|
||||
|
||||
export default function ClientLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const isLoginPage = pathname === '/login'
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoginPage) return
|
||||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(d => { if (d.user) setUser(d.user) })
|
||||
.catch(() => {})
|
||||
}, [isLoginPage])
|
||||
|
||||
if (isLoginPage) {
|
||||
return <ThemeProvider>{children}</ThemeProvider>
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
<Sidebar />
|
||||
<TopBar user={user} />
|
||||
<main className="ml-60 pt-14 min-h-screen">
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
@import 'tailwindcss';
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
@source "../../shared";
|
||||
@source "../../src";
|
||||
/* TLYQ 设计系统 CSS 变量 */
|
||||
@import '../../../docs/design/design-system/tlyq-design-system.css' layer(base);
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--fg);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// src/app/layout.tsx — 根布局
|
||||
// metadata 无法在 client component 中导出,使用单独的 metadata 文件
|
||||
import './globals.css'
|
||||
import ClientLayout from './client-layout'
|
||||
|
||||
export { metadata } from './metadata'
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{
|
||||
__html: `(function(){var t=localStorage.getItem('theme');if(t==='light'||t==='dark')document.documentElement.classList.add(t);else if(window.matchMedia('(prefers-color-scheme:dark)').matches)document.documentElement.classList.add('dark')})()`,
|
||||
}} />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<ClientLayout>{children}</ClientLayout>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// src/app/login/page.tsx — 登录页(引用共享组件)
|
||||
import LoginPage from '@shared/ui/login-page'
|
||||
|
||||
export default function Page() {
|
||||
return <LoginPage siteName="告警监控中心" />
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { Metadata } from 'next'
|
||||
export const metadata: Metadata = {
|
||||
title: 'monitor-ai — 告警监控中心',
|
||||
description: 'TLYQ 基础设施统一监控',
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
'use client'
|
||||
// src/app/page.tsx — 仪表盘首页(四层结构)
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function HomePage() {
|
||||
const [data, setData] = useState<{ services: Array<Record<string,unknown>>; counts: Record<string,number> }>({ services: [], counts: { normal: 0, abnormal: 0, unknown: 0 } })
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/status').then(r => r.json()).then(setData)
|
||||
const timer = setInterval(() => fetch('/api/status').then(r => r.json()).then(setData), 30_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const { services, counts } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 标题 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">服务状态监控 <span className="text-slate-400 dark:text-slate-500 font-normal">· {services.length} 个服务</span></h1>
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">实时健康检查 · 30 秒轮询</p>
|
||||
</div>
|
||||
|
||||
{/* 状态统计条 */}
|
||||
<div className="flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden">
|
||||
<div className="flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-widest">正常</span>
|
||||
<span className="text-2xl font-semibold text-emerald-600 font-[family-name:var(--font-display)]">{counts.normal}</span>
|
||||
<span className="text-xs text-slate-400">共 {services.length} 个监控服务</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-widest">异常</span>
|
||||
<span className="text-2xl font-semibold text-red-600 font-[family-name:var(--font-display)]">{counts.abnormal}</span>
|
||||
<span className="text-xs text-slate-400">{services.filter(s => s.current_status === 'abnormal').map(s => s.name).join(', ') || '—'}</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-widest">未知</span>
|
||||
<span className="text-2xl font-semibold text-amber-600 font-[family-name:var(--font-display)]">{counts.unknown}</span>
|
||||
<span className="text-xs text-slate-400">{counts.unknown > 0 ? '待首次检查' : '全部已检查'}</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1 text-right">
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-widest">最后检查</span>
|
||||
<span className="text-base font-medium text-slate-600 dark:text-slate-300">刚刚</span>
|
||||
<span className="text-xs text-slate-400">自动刷新 30s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 统计条 */}
|
||||
<div className="flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden">
|
||||
<div className="flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-widest">本月可用率</span>
|
||||
<span className="text-2xl font-semibold text-emerald-600 font-[family-name:var(--font-display)]">—</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-slate-400 uppercase tracking-widest">活跃渠道</span>
|
||||
<span className="text-2xl font-semibold font-[family-name:var(--font-display)]">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<hr className="border-t border-slate-200 dark:border-slate-800" />
|
||||
|
||||
{/* 服务卡片网格 */}
|
||||
<div className="grid grid-cols-4 gap-5">
|
||||
{services.length === 0 && (
|
||||
<div className="col-span-4 text-center py-12 text-slate-400 bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800">
|
||||
请先在「服务管理」中添加被监控服务
|
||||
</div>
|
||||
)}
|
||||
{services.map((s) => {
|
||||
const isAbnormal = s.current_status === 'abnormal'
|
||||
return (
|
||||
<div key={String(s.id)} className={`rounded-xl p-5 border transition-all ${isAbnormal ? 'bg-red-50 dark:bg-red-500/10 border-red-600 dark:border-red-400' : 'bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 hover:shadow-sm'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${isAbnormal ? 'bg-red-600 shadow-[0_0_8px_var(--danger)]' : 'bg-emerald-600'}`} />
|
||||
{String(s.name)}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${s.alert_level === 'critical' ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' : 'bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400'}`}>
|
||||
{String(s.alert_level)}
|
||||
</span>
|
||||
</div>
|
||||
{isAbnormal
|
||||
? <div className="text-lg font-semibold font-mono text-red-600 dark:text-red-400">异常</div>
|
||||
: <div className="text-xs text-slate-400">运行中</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
'use client'
|
||||
// src/app/services/page.tsx — 服务管理页面
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Server, Plus, Edit, Trash2, Eye } from 'lucide-react'
|
||||
|
||||
interface Service { id: number; name: string; category: string; alert_level: string; checks: Array<{type: string}>; check_interval: number; enabled: number; current_status: string }
|
||||
|
||||
export default function ServicesPage() {
|
||||
const [services, setServices] = useState<Service[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => { fetchServices() }, [])
|
||||
|
||||
async function fetchServices() {
|
||||
const res = await fetch('/api/services')
|
||||
const data = await res.json()
|
||||
setServices(data)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-12 text-center text-slate-400">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">服务管理</h1>
|
||||
<button className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-4 py-2 text-sm font-medium" onClick={() => alert('添加服务(完整 UI 待 Phase 4 后续实现)')}>
|
||||
<Plus size={16} /> 添加服务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700">
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">名称</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">类别</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">告警级别</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">检查方式</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">状态</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-slate-500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{services.map(s => (
|
||||
<tr key={s.id} className="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<td className="px-4 py-3 font-medium">{s.name}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{s.category}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${s.alert_level === 'critical' ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' : 'bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400'}`}>
|
||||
{s.alert_level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-500">{s.checks.map(c => c.type).join(' + ')}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 ${s.current_status === 'normal' ? 'text-green-600' : s.current_status === 'abnormal' ? 'text-red-600' : 'text-amber-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current" />
|
||||
{s.current_status === 'normal' ? '正常' : s.current_status === 'abnormal' ? '异常' : '未知'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-400" title="查看"><Eye size={16} /></button>
|
||||
<button className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-400" title="编辑"><Edit size={16} /></button>
|
||||
<button className="p-1.5 hover:bg-red-50 dark:hover:bg-red-500/10 rounded text-red-400" title="删除"><Trash2 size={16} /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{services.length === 0 && <p className="p-12 text-center text-slate-400">暂无服务,请添加被监控服务</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
'use client'
|
||||
// src/app/settings/page.tsx — 告警渠道设置(完整 CRUD)
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Bell, Plus, Trash2, Edit3, X, Save, Send } from 'lucide-react'
|
||||
|
||||
interface Channel {
|
||||
id: number
|
||||
name: string
|
||||
channel_type: string
|
||||
webhook_url: string
|
||||
level_critical: number
|
||||
level_warning: number
|
||||
level_info: number
|
||||
cooldown_minutes: number
|
||||
quiet_start: string | null
|
||||
quiet_end: string | null
|
||||
enabled: number
|
||||
}
|
||||
|
||||
const emptyChannel: Partial<Channel> = {
|
||||
name: '', channel_type: 'wecom', webhook_url: '',
|
||||
level_critical: 1, level_warning: 1, level_info: 0,
|
||||
cooldown_minutes: 30, quiet_start: null, quiet_end: null, enabled: 1,
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [editing, setEditing] = useState<Partial<Channel> | null>(null)
|
||||
const [isNew, setIsNew] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState<number | null>(null)
|
||||
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetch('/api/alert-channels').then(r => r.json()).then(setChannels).catch(() => {})
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const showToast = (type: 'ok' | 'err', msg: string) => {
|
||||
setToast({ type, msg })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing?.name || !editing?.webhook_url) { showToast('err', '名称和 Webhook URL 必填'); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
const url = isNew ? '/api/alert-channels' : `/api/alert-channels/${editing.id}`
|
||||
const method = isNew ? 'POST' : 'PUT'
|
||||
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(editing) })
|
||||
if (!res.ok) { const d = await res.json(); showToast('err', d.error || '保存失败'); return }
|
||||
showToast('ok', isNew ? '渠道已创建' : '渠道已更新')
|
||||
setEditing(null)
|
||||
load()
|
||||
} catch { showToast('err', '网络错误') } finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除此渠道?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/alert-channels/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) { showToast('err', '删除失败'); return }
|
||||
showToast('ok', '渠道已删除')
|
||||
load()
|
||||
} catch { showToast('err', '网络错误') }
|
||||
}
|
||||
|
||||
const handleTest = async (id: number) => {
|
||||
setTesting(id)
|
||||
try {
|
||||
const res = await fetch(`/api/alert-channels/${id}/test`, { method: 'POST' })
|
||||
const d = await res.json()
|
||||
showToast(d.success ? 'ok' : 'err', d.success ? '测试消息已发送' : d.error || '发送失败')
|
||||
} catch { showToast('err', '网络错误') } finally { setTesting(null) }
|
||||
}
|
||||
|
||||
const startNew = () => { setEditing({ ...emptyChannel }); setIsNew(true) }
|
||||
const startEdit = (ch: Channel) => { setEditing({ ...ch }); setIsNew(false) }
|
||||
|
||||
const updateField = (field: string, value: unknown) => {
|
||||
setEditing(prev => prev ? { ...prev, [field]: value } : null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg text-sm font-medium shadow-lg ${toast.type === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">告警渠道设置</h1>
|
||||
<button onClick={startNew} className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Plus size={16} /> 添加渠道
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Channel list */}
|
||||
{channels.map(ch => (
|
||||
<div key={ch.id} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell size={18} className="text-indigo-600" />
|
||||
<h2 className="font-semibold">{ch.name}</h2>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-500">{ch.channel_type}</span>
|
||||
{!ch.enabled && <span className="text-xs px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/30 text-red-600">已禁用</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => handleTest(ch.id)} disabled={testing === ch.id} className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 rounded-lg transition-colors" title="测试推送">
|
||||
<Send size={15} className={testing === ch.id ? 'animate-pulse' : ''} />
|
||||
</button>
|
||||
<button onClick={() => startEdit(ch)} className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 rounded-lg transition-colors" title="编辑">
|
||||
<Edit3 size={15} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(ch.id)} className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors" title="删除">
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div><span className="text-slate-400">Webhook:</span> <span className="font-mono text-xs">{ch.webhook_url.slice(0, 50)}{ch.webhook_url.length > 50 ? '...' : ''}</span></div>
|
||||
<div><span className="text-slate-400">级别:</span> {ch.level_critical ? 'critical ' : ''}{ch.level_warning ? 'warning ' : ''}{ch.level_info ? 'info' : ''}</div>
|
||||
<div><span className="text-slate-400">冷却:</span> {ch.cooldown_minutes}分钟</div>
|
||||
</div>
|
||||
{(ch.quiet_start || ch.quiet_end) && (
|
||||
<div className="mt-2 text-sm"><span className="text-slate-400">免打扰:</span> {ch.quiet_start} — {ch.quiet_end}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{channels.length === 0 && <p className="text-center text-slate-400 py-12">暂无告警渠道</p>}
|
||||
|
||||
{/* Edit/New modal */}
|
||||
{editing && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 w-full max-w-lg shadow-xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">{isNew ? '添加告警渠道' : '编辑告警渠道'}</h2>
|
||||
<button onClick={() => setEditing(null)} className="p-1 text-slate-400 hover:text-slate-600"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">名称 *</label>
|
||||
<input value={editing.name || ''} onChange={e => updateField('name', e.target.value)} placeholder="如:运维告警群"
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Webhook URL *</label>
|
||||
<input value={editing.webhook_url || ''} onChange={e => updateField('webhook_url', e.target.value)} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">冷却(分钟)</label>
|
||||
<input type="number" value={editing.cooldown_minutes || 30} onChange={e => updateField('cooldown_minutes', Number(e.target.value))}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">渠道类型</label>
|
||||
<select value={editing.channel_type || 'wecom'} onChange={e => updateField('channel_type', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||||
<option value="wecom">企业微信</option>
|
||||
<option value="webhook">通用 Webhook</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">告警级别</label>
|
||||
<div className="flex gap-4">
|
||||
{[
|
||||
{ key: 'level_critical', label: 'Critical' },
|
||||
{ key: 'level_warning', label: 'Warning' },
|
||||
{ key: 'level_info', label: 'Info' },
|
||||
].map(l => (
|
||||
<label key={l.key} className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={!!(editing as Record<string, unknown>)[l.key]} onChange={e => updateField(l.key, e.target.checked ? 1 : 0)}
|
||||
className="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
{l.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" checked={!!editing.quiet_start} onChange={e => {
|
||||
if (e.target.checked) {
|
||||
updateField('quiet_start', '23:00')
|
||||
updateField('quiet_end', '07:00')
|
||||
} else {
|
||||
updateField('quiet_start', null)
|
||||
updateField('quiet_end', null)
|
||||
}
|
||||
}}
|
||||
className="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
免打扰时段
|
||||
</label>
|
||||
</div>
|
||||
{editing.quiet_start && (
|
||||
<div className="grid grid-cols-2 gap-4 pl-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">开始时间</label>
|
||||
<input type="time" value={editing.quiet_start || ''} onChange={e => updateField('quiet_start', e.target.value || null)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">结束时间</label>
|
||||
<input type="time" value={editing.quiet_end || ''} onChange={e => updateField('quiet_end', e.target.value || null)}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input type="checkbox" checked={editing.enabled === 1} onChange={e => updateField('enabled', e.target.checked ? 1 : 0)}
|
||||
className="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
启用渠道
|
||||
</label>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors">取消</button>
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<Save size={15} /> {saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
'use client'
|
||||
// src/app/status-history/page.tsx — 状态变更历史
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock } from 'lucide-react'
|
||||
|
||||
export default function StatusHistoryPage() {
|
||||
const [events, setEvents] = useState<Array<Record<string,unknown>>>([])
|
||||
useEffect(() => { fetch('/api/status-history').then(r => r.json()).then(setEvents) }, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">状态变更历史</h1>
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 dark:bg-slate-800">
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">服务</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">状态变化</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">开始时间</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">恢复时间</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-500">持续</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e: Record<string,unknown>) => (
|
||||
<tr key={String(e.id)} className={`border-b border-slate-100 dark:border-slate-800 ${!e.ended_at ? 'bg-red-50 dark:bg-red-500/5' : ''}`}>
|
||||
<td className="px-4 py-3 font-medium">{String(e.service_name)}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{String(e.from_status)} → {String(e.to_status)}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{String(e.started_at).slice(0,19)}</td>
|
||||
<td className="px-4 py-3 text-slate-500">{e.ended_at ? String(e.ended_at).slice(0,19) : '进行中'}</td>
|
||||
<td className="px-4 py-3">{e.duration_seconds ? `${Math.floor(Number(e.duration_seconds)/60)}m${Number(e.duration_seconds)%60}s` : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{events.length === 0 && <p className="p-12 text-center text-slate-400">暂无状态变更记录</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
'use client'
|
||||
// src/components/Sidebar.tsx — 权限驱动侧边栏(遵循设计系统「统一管理布局」)
|
||||
import { usePathname } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { LayoutDashboard, Server, Bell, Clock, Shield, Users, FileText, Activity } from 'lucide-react'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
interface NavItem { label: string; href: string; icon: React.ComponentType<{ size?: number }>; permission?: string }
|
||||
interface NavSection { title?: string; items: NavItem[] }
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
|
||||
|
||||
// 默认显示所有导航项(客户端权限在 API 层强制验证,此处仅 UI 过滤)
|
||||
const userRole = 'admin' // TODO: 从 cookie 或 session 读取实际角色
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
items: [
|
||||
{ label: '仪表盘', href: '/', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '监控',
|
||||
items: [
|
||||
{ label: '服务管理', href: '/services', icon: Server, permission: 'services:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '告警',
|
||||
items: [
|
||||
{ label: '告警设置', href: '/settings', icon: Bell, permission: 'alerts:manage' },
|
||||
{ label: '告警历史', href: '/alerts', icon: Activity, permission: 'alerts:view' },
|
||||
{ label: '状态历史', href: '/status-history', icon: Clock, permission: 'status_history:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '管理',
|
||||
items: [
|
||||
{ label: '用户管理', href: '/admin/users', icon: Users, permission: 'users:view' },
|
||||
{ label: '角色权限', href: '/admin/roles', icon: Shield, permission: 'roles:manage' },
|
||||
{ label: '审计日志', href: '/admin/audit-logs', icon: FileText, permission: 'audit:view' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 bottom-0 w-60 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-[200] flex flex-col">
|
||||
{/* 品牌区 */}
|
||||
<div className="h-14 flex items-center px-6 border-b border-slate-200 dark:border-slate-800 shrink-0">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white font-bold text-sm">M</div>
|
||||
<span className="font-semibold text-slate-900 dark:text-white">monitor-ai</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 导航区 */}
|
||||
<nav className="flex-1 overflow-y-auto p-3 space-y-6">
|
||||
{sections.map((section, i) => (
|
||||
<div key={i}>
|
||||
{section.title && (
|
||||
<p className="px-3 mb-1 text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||
{section.title}
|
||||
</p>
|
||||
)}
|
||||
{/* 客户端仅控制导航可见性,实际权限由 middleware + API 验证 */}
|
||||
{section.items.filter(item => !item.permission || hasPermission(userRole, item.permission)).map(item => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors mb-0.5 ${
|
||||
isActive(item.href)
|
||||
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400'
|
||||
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 用户信息和退出在 TopBar 中,此处不重复 */}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
'use client'
|
||||
// src/components/ThemeProvider.tsx — 主题切换(light/dark/auto)
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'auto'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
setTheme: (t: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType>({ theme: 'auto', setTheme: () => {} })
|
||||
|
||||
export function useTheme() { return useContext(ThemeContext) }
|
||||
|
||||
export default function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>('auto')
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('theme') as Theme | null
|
||||
if (saved) { setThemeState(saved); applyTheme(saved) }
|
||||
}, [])
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
setThemeState(t)
|
||||
localStorage.setItem('theme', t)
|
||||
applyTheme(t)
|
||||
}
|
||||
|
||||
return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
function applyTheme(t: Theme) {
|
||||
const root = document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
if (t === 'light') root.classList.add('light')
|
||||
else if (t === 'dark') root.classList.add('dark')
|
||||
else if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark')
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
'use client'
|
||||
// src/components/ThemeToggle.tsx — 主题切换按钮
|
||||
import { useTheme } from './ThemeProvider'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'auto' : 'light'
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(next)}
|
||||
className="w-9 h-9 rounded-lg border border-slate-200 dark:border-slate-700 flex items-center justify-center text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
title={`当前: ${theme},切换为 ${next}`}
|
||||
>
|
||||
{theme === 'dark' ? <Moon size={16} /> : <Sun size={16} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
'use client'
|
||||
// src/components/TopBar.tsx — 顶栏:主题切换下拉 + 用户信息 + 退出
|
||||
import { useTheme } from '@/components/ThemeProvider'
|
||||
import { Sun, Moon, LogOut, User } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'auto'
|
||||
|
||||
interface TopBarProps { user: { username: string; display_name: string; role: string } | null }
|
||||
|
||||
export default function TopBar({ user }: TopBarProps) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' })
|
||||
router.push('/login')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const icons: Record<Theme, React.ReactNode> = {
|
||||
light: <Sun size={16} />,
|
||||
dark: <Moon size={16} />,
|
||||
auto: <span style={{ fontSize: 14 }}>◐</span>,
|
||||
}
|
||||
const labels: Record<Theme, string> = { light: '浅色', dark: '深色', auto: '自动' }
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-60 right-0 h-14 bg-white dark:bg-slate-900
|
||||
border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
|
||||
<div />
|
||||
<div className="flex items-center gap-4">
|
||||
<div ref={ref} className="relative">
|
||||
<button onClick={() => setOpen(!open)}
|
||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400
|
||||
dark:hover:bg-slate-800 transition-colors"
|
||||
title="切换主题">
|
||||
{icons[theme]}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1 bg-white dark:bg-slate-800
|
||||
border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 py-1 min-w-[100px]">
|
||||
{(['light', 'dark', 'auto'] as Theme[]).map(t => (
|
||||
<button key={t} onClick={() => { setTheme(t); setOpen(false) }}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left ${
|
||||
t === theme ? 'bg-slate-100 dark:bg-slate-700' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50'
|
||||
}`}>
|
||||
<span>{icons[t]}</span>
|
||||
<span>{labels[t]}</span>
|
||||
{t === theme && <span className="ml-auto text-indigo-600">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<User size={16} />
|
||||
<span>{user.display_name || user.username}</span>
|
||||
</div>
|
||||
<button onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:text-slate-400
|
||||
dark:hover:bg-slate-800 transition-colors"
|
||||
title="退出登录">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// src/lib/audit.ts — 审计日志封装(引用 @shared/lib/audit)
|
||||
import { writeAuditLog as writeLog, type AuditLogEntry } from '@shared/lib/audit/write-audit-log'
|
||||
import { dbExec } from './db'
|
||||
|
||||
// 封装版:直接传入参数,自动处理 dbExec
|
||||
export function writeAuditLog(entry: AuditLogEntry): void {
|
||||
writeLog({ exec: dbExec }, entry)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// src/lib/auth-config.ts — 认证配置(使用共享工厂)
|
||||
import { createAuthConfig } from '@shared/lib/auth/config'
|
||||
|
||||
export const authConfig = createAuthConfig({
|
||||
siteName: 'monitor-ai',
|
||||
adminPaths: ['/admin', '/services', '/settings', '/alerts'],
|
||||
})
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// src/lib/config.ts — 环境变量读取
|
||||
export const config = {
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
databasePath: process.env.DATABASE_PATH || './data/monitor.db',
|
||||
monitorMode: (process.env.MONITOR_MODE || 'dev') as 'local' | 'remote' | 'dev',
|
||||
|
||||
// 共享 JWT
|
||||
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret',
|
||||
cookieDomain: process.env.COOKIE_DOMAIN || '.tlyq.ai',
|
||||
|
||||
// OIDC SSO
|
||||
autheliaUrl: process.env.AUTHELIA_URL || 'https://authelia:9091',
|
||||
oidcClientId: process.env.OIDC_CLIENT_ID || 'monitor-oidc',
|
||||
oidcClientSecret: process.env.OIDC_CLIENT_SECRET || 'dev-secret',
|
||||
oidcRedirectUri: process.env.OIDC_REDIRECT_URI || 'http://localhost:6181/api/auth/callback',
|
||||
|
||||
// LLDAP
|
||||
ldapUrl: process.env.LDAP_URL || 'ldap://ldap-ai:3890',
|
||||
|
||||
// localadmin
|
||||
localadminPassword: process.env.LOCALADMIN_PASSWORD || 'admin123',
|
||||
|
||||
// TLS
|
||||
tlsRejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0',
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
// src/lib/db.ts — SQLite 数据库操作封装(execFileSync)
|
||||
import { execFileSync } from 'child_process'
|
||||
import { config } from './config'
|
||||
import {
|
||||
SERVICES_TABLE_SQL, ALERT_CHANNELS_TABLE_SQL, ALERT_SETTINGS_TABLE_SQL,
|
||||
STATUS_HISTORY_TABLE_SQL, ALERT_HISTORY_TABLE_SQL,
|
||||
} from '@shared/lib/db/alert-schema'
|
||||
import { AUDIT_LOGS_TABLE_SQL } from '@shared/lib/audit/audit-schema'
|
||||
|
||||
const DB_PATH = config.databasePath
|
||||
|
||||
// SQL 值转义(防 SQL 注入)
|
||||
export function escapeSql(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'NULL'
|
||||
if (typeof value === 'number') {
|
||||
if (!isFinite(value)) return 'NULL'
|
||||
return String(value)
|
||||
}
|
||||
return `'${String(value).replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
// 确保数据库已初始化(使用原始 exec 避免循环调用)
|
||||
let dbInitialized = false
|
||||
function ensureDb() {
|
||||
if (!dbInitialized) {
|
||||
dbInitialized = true
|
||||
const execRaw = (sql: string) => execFileSync('sqlite3', [DB_PATH, sql], { timeout: 10000 })
|
||||
execRaw('PRAGMA journal_mode=WAL')
|
||||
execRaw('PRAGMA busy_timeout=5000')
|
||||
execRaw(SERVICES_TABLE_SQL)
|
||||
execRaw(ALERT_CHANNELS_TABLE_SQL)
|
||||
execRaw(ALERT_SETTINGS_TABLE_SQL)
|
||||
execRaw(STATUS_HISTORY_TABLE_SQL)
|
||||
execRaw(ALERT_HISTORY_TABLE_SQL)
|
||||
execRaw(AUDIT_LOGS_TABLE_SQL)
|
||||
execRaw(`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, display_name TEXT, email TEXT, role TEXT DEFAULT 'viewer', password_hash TEXT, is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now', '+8 hours')), updated_at TEXT DEFAULT (datetime('now', '+8 hours')))`)
|
||||
execRaw(`CREATE TABLE IF NOT EXISTS permissions (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE NOT NULL, name TEXT NOT NULL, description TEXT, created_at TEXT DEFAULT (datetime('now', '+8 hours')))`)
|
||||
execRaw(`CREATE TABLE IF NOT EXISTS role_permissions (id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL, permission_key TEXT NOT NULL REFERENCES permissions(key), created_at TEXT DEFAULT (datetime('now', '+8 hours')), UNIQUE(role, permission_key))`)
|
||||
const bcrypt = require('bcryptjs')
|
||||
const pwdHash = bcrypt.hashSync(config.localadminPassword, 10)
|
||||
execRaw(`INSERT OR IGNORE INTO users (username, display_name, role, password_hash) VALUES ('localadmin', '超级管理员', 'admin', '${pwdHash.replace(/'/g, "''")}')`)
|
||||
const raw = execFileSync('sqlite3', ['-json', DB_PATH, 'SELECT id FROM alert_channels'], { encoding: 'utf-8', timeout: 10000 }).trim()
|
||||
const channels = raw ? JSON.parse(raw) : []
|
||||
if (channels.length === 0) {
|
||||
execRaw(`INSERT INTO alert_channels (name, webhook_url, channel_type, level_critical, level_warning, level_info, quiet_enabled, quiet_start, quiet_end, quiet_bypass_critical) VALUES ('运维群', '', 'wechat', 1, 1, 0, 1, '23:00', '07:00', 1)`)
|
||||
}
|
||||
console.log('[db] Database initialized:', DB_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 execFileSync 直接传参,不经过 shell
|
||||
export function dbExec(sql: string): void {
|
||||
ensureDb()
|
||||
execFileSync('sqlite3', [DB_PATH, sql], { timeout: 10000 })
|
||||
}
|
||||
|
||||
export function dbQuery<T = Record<string, unknown>>(sql: string): T[] {
|
||||
ensureDb()
|
||||
const stdout = execFileSync('sqlite3', ['-json', DB_PATH, sql], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 10000,
|
||||
})
|
||||
return stdout.trim() ? JSON.parse(stdout) : []
|
||||
}
|
||||
|
||||
// 参数化查询(推荐使用)
|
||||
export function dbQueryParams<T = Record<string, unknown>>(sql: string, params: unknown[]): T[] {
|
||||
let i = 0
|
||||
const escaped = sql.replace(/\?/g, () => escapeSql(params[i++]))
|
||||
return dbQuery<T>(escaped)
|
||||
}
|
||||
|
||||
// 初始化数据库:建表 + 默认数据 + WAL 模式(公开导出,内部复用 ensureDb)
|
||||
export function initDatabase(): void { ensureDb() }
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
// src/lib/monitor-worker.ts — 后台健康检查循环
|
||||
import { dbQuery, dbExec, escapeSql } from './db'
|
||||
import { config } from './config'
|
||||
import { HttpChecker, DockerChecker, HealthChecker } from '@shared/lib/alert/health-checker'
|
||||
import { AlertManager } from '@shared/lib/alert/alert-manager'
|
||||
import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher'
|
||||
import type { AlertChannelConfig, ServiceStatus, AlertLevel } from '@shared/lib/alert/types'
|
||||
|
||||
// SQLite 返回 snake_case,与 DbService camelCase 不同,使用此类型
|
||||
interface DbService { id: number; name: string; category: string; alert_level: string; checks: string; check_interval: number; check_timeout: number; enabled: number; current_status: string; status_since: string; display_order: number }
|
||||
interface WorkerStatus { ticking: boolean; lastTick: number; tickCount: number; startTime: number }
|
||||
|
||||
export class MonitorWorker {
|
||||
ticking = false
|
||||
lastTick = 0
|
||||
tickCount = 0
|
||||
startTime = Date.now()
|
||||
private healthChecker: HealthChecker
|
||||
private alertManager: AlertManager
|
||||
private flappingCounter = new Map<number, number[]>()
|
||||
private flappingAlertSent = new Set<number>()
|
||||
private lastCleanupDate = ''
|
||||
|
||||
constructor() {
|
||||
this.healthChecker = new HealthChecker()
|
||||
this.healthChecker.registerChecker('http', new HttpChecker())
|
||||
this.healthChecker.registerChecker('docker', new DockerChecker())
|
||||
|
||||
this.alertManager = new AlertManager({
|
||||
getLastAlertTime: async (channelId, serviceId, level) => {
|
||||
const rows = dbQuery<{ value: string }>(`SELECT value FROM alert_settings WHERE key = 'cooldown:${channelId}:${serviceId}:${level}'`)
|
||||
return rows.length > 0 ? Number(rows[0].value) : null
|
||||
},
|
||||
setLastAlertTime: async (channelId, serviceId, level, timestamp) => {
|
||||
dbExec(`INSERT OR REPLACE INTO alert_settings (key, value, description, updated_at) VALUES ('cooldown:${escapeSql(String(channelId))}:${escapeSql(String(serviceId))}:${escapeSql(level)}', '${escapeSql(String(timestamp))}', 'alert cooldown', datetime('now', '+8 hours'))`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// 恢复逻辑:重启时重置所有状态为 unknown
|
||||
dbExec("UPDATE services SET current_status = 'unknown', status_since = datetime('now', '+8 hours') WHERE current_status != 'unknown'")
|
||||
dbExec(`UPDATE status_history SET ended_at = datetime('now', '+8 hours'), duration_seconds = (strftime('%s', 'now') - strftime('%s', started_at)), truncated_by_restart = 1 WHERE ended_at IS NULL`)
|
||||
|
||||
console.log('[worker] Started. State recovered.')
|
||||
this.tick()
|
||||
setInterval(() => this.tick(), 30_000)
|
||||
}
|
||||
|
||||
private async tick(): Promise<void> {
|
||||
if (this.ticking) { console.log('[worker] Skipping tick, previous not completed'); return }
|
||||
this.ticking = true
|
||||
try {
|
||||
this.lastTick = Date.now()
|
||||
this.tickCount++
|
||||
|
||||
const services = dbQuery<DbService>('SELECT *, checks AS checks FROM services WHERE enabled = 1')
|
||||
for (const svc of services) {
|
||||
try {
|
||||
const checks = JSON.parse(String(svc.checks || '[]'))
|
||||
const results = await this.healthChecker.check(checks, Number(svc.check_timeout) || 10)
|
||||
|
||||
const allSuccess = results.every(r => r.success)
|
||||
const newStatus: ServiceStatus = allSuccess ? 'normal' : 'abnormal'
|
||||
const oldStatus = svc.current_status as ServiceStatus
|
||||
|
||||
if (newStatus !== oldStatus) {
|
||||
// 抖动检测
|
||||
this.trackFlapping(Number(svc.id), newStatus)
|
||||
const isFlapping = this.isFlapping(Number(svc.id))
|
||||
|
||||
await this.handleStatusChange(svc as unknown as DbService, oldStatus, newStatus, results, isFlapping)
|
||||
} else if (newStatus === 'abnormal') {
|
||||
// 持续异常超 30 分钟发提醒
|
||||
this.sendProlongedReminder(svc as unknown as DbService)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[worker] Error checking service ${svc.id}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// WAL checkpoint(每小时)
|
||||
if (this.tickCount % 120 === 0) {
|
||||
dbExec('PRAGMA wal_checkpoint(PASSIVE)')
|
||||
}
|
||||
|
||||
// 数据清理(每日,UTC+8 时区)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10)
|
||||
if (today !== this.lastCleanupDate) {
|
||||
this.cleanupOldData()
|
||||
this.lastCleanupDate = today
|
||||
}
|
||||
} finally {
|
||||
this.ticking = false
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStatusChange(svc: DbService, oldStatus: ServiceStatus, newStatus: ServiceStatus, results: Array<{success: boolean; error?: string; checkType: string}>, isFlapping: boolean): Promise<void> {
|
||||
if (oldStatus === 'unknown' && newStatus === 'normal') {
|
||||
// 首次检查正常,记录但不告警
|
||||
dbExec(`INSERT INTO status_history (service_id, from_status, to_status, started_at) VALUES (${svc.id}, 'unknown', 'normal', datetime('now', '+8 hours'))`)
|
||||
dbExec(`UPDATE services SET current_status = 'normal', status_since = datetime('now', '+8 hours'), updated_at = datetime('now', '+8 hours') WHERE id = ${svc.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (newStatus === 'abnormal') {
|
||||
const failedChecks = results.filter(r => !r.success).map(r => ({ type: r.checkType, error: r.error }))
|
||||
const errorMsg = failedChecks.map(c => c.error).join('; ')
|
||||
|
||||
dbExec(`INSERT INTO status_history (service_id, from_status, to_status, started_at, error_message, failed_checks) VALUES (${svc.id}, '${oldStatus}', 'abnormal', datetime('now', '+8 hours'), ${escapeSql(errorMsg)}, ${escapeSql(JSON.stringify(failedChecks))})`)
|
||||
dbExec(`UPDATE services SET current_status = 'abnormal', status_since = datetime('now', '+8 hours'), updated_at = datetime('now', '+8 hours') WHERE id = ${svc.id}`)
|
||||
|
||||
if (isFlapping) {
|
||||
// 仅在首次检测到抖动时发一条汇总告警
|
||||
const alreadyFlapping = this.flappingAlertSent.has(svc.id)
|
||||
if (!alreadyFlapping) {
|
||||
await this.sendFlappingAlert(svc)
|
||||
this.flappingAlertSent.add(svc.id)
|
||||
}
|
||||
// 抖动期间跳过个别告警
|
||||
} else {
|
||||
this.flappingAlertSent.delete(svc.id)
|
||||
const level: AlertLevel = svc.alert_level as AlertLevel
|
||||
await this.evaluateAndPush(svc, level)
|
||||
}
|
||||
} else {
|
||||
// 恢复
|
||||
dbExec(`UPDATE status_history SET ended_at = datetime('now', '+8 hours'), duration_seconds = (strftime('%s', 'now') - strftime('%s', started_at)) WHERE service_id = ${svc.id} AND ended_at IS NULL`)
|
||||
dbExec(`UPDATE services SET current_status = 'normal', status_since = datetime('now', '+8 hours'), updated_at = datetime('now', '+8 hours') WHERE id = ${svc.id}`)
|
||||
|
||||
// 恢复通知(不受免打扰限制)
|
||||
await this.sendRecovery(svc)
|
||||
}
|
||||
}
|
||||
|
||||
private async evaluateAndPush(svc: DbService, level: AlertLevel): Promise<void> {
|
||||
const channels = dbQuery<AlertChannelConfig & { enabled_num: number; level_critical_num: number; level_warning_num: number; level_info_num: number }>(
|
||||
'SELECT *, enabled AS enabled_num, level_critical AS level_critical_num, level_warning AS level_warning_num, level_info AS level_info_num FROM alert_channels WHERE enabled = 1'
|
||||
)
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
channels.map(async (ch) => {
|
||||
const channel: AlertChannelConfig = {
|
||||
id: ch.id as number,
|
||||
name: ch.name as string,
|
||||
webhookUrl: (ch as Record<string, unknown>).webhook_url as string || '',
|
||||
channelType: (ch as Record<string, unknown>).channel_type as string || 'wechat',
|
||||
enabled: !!ch.enabled_num,
|
||||
levelCritical: !!ch.level_critical_num,
|
||||
levelWarning: !!ch.level_warning_num,
|
||||
levelInfo: !!ch.level_info_num,
|
||||
quietEnabled: !!(ch as Record<string, unknown>).quiet_enabled,
|
||||
quietStart: (ch as Record<string, unknown>).quiet_start as string || '23:00',
|
||||
quietEnd: (ch as Record<string, unknown>).quiet_end as string || '07:00',
|
||||
quietBypassCritical: !!(ch as Record<string, unknown>).quiet_bypass_critical,
|
||||
cooldownMinutes: Number((ch as Record<string, unknown>).cooldown_minutes) || 5,
|
||||
}
|
||||
const decision = await this.alertManager.evaluate(channel, svc.id, level)
|
||||
if (decision.shouldPush) {
|
||||
const pusher = new WeChatPusher(channel.webhookUrl)
|
||||
const msg = `级别: ${level}\n服务: ${svc.name}\n状态: 异常\n时间: ${new Date().toLocaleString('zh-CN')}`
|
||||
const result = await pusher.pushMarkdown('服务异常告警', msg)
|
||||
await this.alertManager.recordAlert(channel.id, svc.id, level)
|
||||
dbExec(`INSERT INTO alert_history (channel_id, service_id, level, title, content, sent_at, success, response_code) VALUES (${channel.id}, ${svc.id}, '${level}', '服务异常告警', ${escapeSql(msg)}, datetime('now', '+8 hours'), ${result.success ? 1 : 0}, ${result.responseCode ?? 0})`)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private async sendRecovery(svc: DbService): Promise<void> {
|
||||
// 仅推送到启用了对应告警级别的 channel
|
||||
const levelCol = svc.alert_level === 'critical' ? 'level_critical' : 'level_warning'
|
||||
const channels = dbQuery<{ id: number; webhook_url: string }>(
|
||||
`SELECT id, webhook_url FROM alert_channels WHERE enabled = 1 AND ${levelCol} = 1`
|
||||
)
|
||||
await Promise.allSettled(channels.map(async (ch) => {
|
||||
const pusher = new WeChatPusher(String(ch.webhook_url))
|
||||
const msg = `服务: ${svc.name}\n状态: 已恢复\n时间: ${new Date().toLocaleString('zh-CN')}`
|
||||
const result = await pusher.pushMarkdown('服务已恢复', msg)
|
||||
dbExec(`INSERT INTO alert_history (channel_id, service_id, status_event_id, alert_type, level, title, content, sent_at, success, response_code)
|
||||
VALUES (${ch.id}, ${svc.id}, (SELECT id FROM status_history WHERE service_id = ${svc.id} AND ended_at IS NOT NULL ORDER BY id DESC LIMIT 1), 'recovery', 'info', '服务已恢复', ${escapeSql(msg)}, datetime('now', '+8 hours'), ${result.success ? 1 : 0}, ${result.responseCode ?? 0})`)
|
||||
}))
|
||||
}
|
||||
|
||||
private async sendFlappingAlert(svc: DbService): Promise<void> {
|
||||
const channels = dbQuery<{ id: number; webhook_url: string }>('SELECT id, webhook_url FROM alert_channels WHERE enabled = 1 AND level_warning = 1')
|
||||
await Promise.allSettled(channels.map(async (ch) => {
|
||||
const pusher = new WeChatPusher(String(ch.webhook_url))
|
||||
const msg = `服务: ${svc.name}\n近 10 分钟内频繁状态切换\n抖动期间暂停该服务的个别告警推送`
|
||||
await pusher.pushMarkdown('服务状态不稳定', msg)
|
||||
}))
|
||||
}
|
||||
|
||||
// 抖动检测:追踪所有状态切换(abnormal + recovery)
|
||||
private trackFlapping(serviceId: number, _toStatus: ServiceStatus): void {
|
||||
const timestamps = this.flappingCounter.get(serviceId) || []
|
||||
timestamps.push(Date.now())
|
||||
const cutoff = Date.now() - 10 * 60 * 1000
|
||||
this.flappingCounter.set(serviceId, timestamps.filter(t => t > cutoff))
|
||||
}
|
||||
|
||||
private isFlapping(serviceId: number): boolean {
|
||||
const timestamps = this.flappingCounter.get(serviceId) || []
|
||||
return timestamps.length >= 5
|
||||
}
|
||||
|
||||
// 持续异常提醒(>30分钟)
|
||||
private sendProlongedReminder(svc: DbService): void {
|
||||
const key = `reminder:${svc.id}`
|
||||
const rows = dbQuery<{ value: string }>(`SELECT value FROM alert_settings WHERE key = '${key}'`)
|
||||
const lastReminder = rows.length > 0 ? Number(rows[0].value) : 0
|
||||
const now = Date.now()
|
||||
if (now - lastReminder < 30 * 60 * 1000) return // 30分钟内已提醒过
|
||||
|
||||
// 发送提醒
|
||||
const channels = dbQuery<{ id: number; webhook_url: string }>('SELECT id, webhook_url FROM alert_channels WHERE enabled = 1 AND level_warning = 1')
|
||||
Promise.allSettled(channels.map(async (ch) => {
|
||||
const pusher = new WeChatPusher(String(ch.webhook_url))
|
||||
await pusher.pushMarkdown('服务持续异常', `服务: ${svc.name}\n已持续异常超过 30 分钟\n时间: ${new Date().toLocaleString('zh-CN')}`)
|
||||
}))
|
||||
|
||||
dbExec(`INSERT OR REPLACE INTO alert_settings (key, value, description, updated_at) VALUES ('${key}', '${now}', 'prolonged reminder', datetime('now', '+8 hours'))`)
|
||||
}
|
||||
|
||||
// 数据清理(180 天)
|
||||
private cleanupOldData(): void {
|
||||
dbExec(`DELETE FROM alert_history WHERE id IN (SELECT id FROM alert_history WHERE sent_at < datetime('now', '-180 days', '+8 hours') LIMIT 1000)`)
|
||||
dbExec(`DELETE FROM status_history WHERE id IN (SELECT id FROM status_history WHERE created_at < datetime('now', '-180 days', '+8 hours') AND ended_at IS NOT NULL LIMIT 1000)`)
|
||||
console.log('[worker] Data cleanup completed')
|
||||
}
|
||||
|
||||
getStatus(): WorkerStatus {
|
||||
return { ticking: this.ticking, lastTick: this.lastTick, tickCount: this.tickCount, startTime: this.startTime }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// src/lib/permissions.ts — RBAC 权限定义 + 检查函数
|
||||
export const PERMISSIONS = [
|
||||
{ key: 'dashboard:view', name: '查看仪表盘' },
|
||||
{ key: 'services:view', name: '查看服务列表' },
|
||||
{ key: 'services:manage', name: '管理服务' },
|
||||
{ key: 'alerts:view', name: '查看告警历史' },
|
||||
{ key: 'alerts:manage', name: '管理告警渠道' },
|
||||
{ key: 'status_history:view', name: '查看状态历史' },
|
||||
{ key: 'status_history:stats', name: '查看可用率统计' },
|
||||
{ key: 'audit:view', name: '查看审计日志' },
|
||||
{ key: 'users:view', name: '查看用户列表' },
|
||||
{ key: 'users:manage', name: '管理用户角色' },
|
||||
{ key: 'roles:manage', name: '管理角色权限' },
|
||||
] as const
|
||||
|
||||
// 角色默认权限映射
|
||||
export const ROLE_DEFAULT_PERMISSIONS: Record<string, string[]> = {
|
||||
admin: PERMISSIONS.map(p => p.key),
|
||||
editor: ['dashboard:view', 'services:view', 'alerts:view', 'status_history:view', 'status_history:stats'],
|
||||
viewer: ['dashboard:view', 'services:view', 'status_history:view'],
|
||||
}
|
||||
|
||||
// 检查角色是否拥有某项权限
|
||||
export function hasPermission(role: string, permissionKey: string): boolean {
|
||||
if (role === 'localadmin') return true
|
||||
if (role === 'admin') return true // admin 拥有全部权限
|
||||
const perms = ROLE_DEFAULT_PERMISSIONS[role]
|
||||
return perms ? perms.includes(permissionKey) : false
|
||||
}
|
||||
|
||||
// API 服务端权限检查(从 cookie 中读取 role)
|
||||
export function checkPermission(request: { cookies: { get(name: string): { value: string } | undefined } }, permissionKey: string): boolean {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
if (!token) return false
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString())
|
||||
const role = payload.role || 'viewer'
|
||||
return hasPermission(role, permissionKey)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// src/middleware.ts — 使用共享 middleware 工厂
|
||||
import { createMiddleware } from '@shared/lib/auth/middleware'
|
||||
|
||||
export const middleware = createMiddleware({
|
||||
adminPaths: ['/admin', '/services', '/settings', '/alerts'],
|
||||
})
|
||||
|
||||
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["./shared/*"]
|
||||
},
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue