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:
aiyimickey 2026-07-01 18:41:46 +08:00
commit 89acbe3bf6
60 changed files with 6894 additions and 0 deletions

23
.env.example Normal file
View File

@ -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

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
.next/
data/*.db
.env
.env.local
*.log
.DS_Store
.DS_Store

23
CHANGELOG.md Normal file
View File

@ -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任一退出则容器退出

501
CLAUDE.md Normal file
View File

@ -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` |
| 服务器 | txjpIP: 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共享 JWTdomain=.tlyq.ai
中间件验证 JWT payloadEdge 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 checkpointPASSIVE |
| 数据清理 | 每天 | 删除 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
└── webnetexternal ← 共享网络
```
部署:`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')`
- **审计日志**:所有写操作 APIPOST/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`

28
Dockerfile Normal file
View File

@ -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"]

22
docker-compose.yml Normal file
View File

@ -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

View File

@ -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 类型断言可优化 | 待优化(不影响功能)|
---
**审查结论:连续两轮通过,审查结束。**

37
entrypoint.sh Normal file
View File

@ -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

6
next-env.d.ts vendored Normal file
View File

@ -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.

8
next.config.ts Normal file
View File

@ -0,0 +1,8 @@
import type { NextConfig } from 'next'
const config: NextConfig = {
output: 'standalone',
transpilePackages: ['lucide-react'],
}
export default config

2508
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -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"
}
}

6
postcss.config.mjs Normal file
View File

@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1260
preview/dashboard.html Normal file

File diff suppressed because it is too large Load Diff

431
scripts/deploy-monitor.sh Executable file
View File

@ -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

142
scripts/monitor-watchdog.sh Executable file
View File

@ -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 "$@"

17
scripts/monitor-worker.ts Normal file
View File

@ -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.')

1
shared Symbolic link
View File

@ -0,0 +1 @@
../shared

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

40
src/app/alerts/page.tsx Normal file
View File

@ -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>
)
}

View File

@ -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() })
}

View File

@ -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 })
}

View File

@ -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)
}

View File

@ -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 })
}

View File

@ -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 })
}

View File

@ -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
}

View File

@ -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 cookie5 分钟过期)
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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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',
},
})
}

View File

@ -0,0 +1,4 @@
// GET /api/health — monitor-ai 自身健康检查
export async function GET() {
return Response.json({ status: 'OK', timestamp: new Date().toISOString() })
}

View File

@ -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 })
}

View File

@ -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 })
}

View File

@ -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 })
}

View File

@ -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)
}

View File

@ -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() })
}

39
src/app/client-layout.tsx Normal file
View File

@ -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>
)
}

16
src/app/globals.css Normal file
View File

@ -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);
}
}

21
src/app/layout.tsx Normal file
View File

@ -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>
)
}

6
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,6 @@
// src/app/login/page.tsx — 登录页(引用共享组件)
import LoginPage from '@shared/ui/login-page'
export default function Page() {
return <LoginPage siteName="告警监控中心" />
}

5
src/app/metadata.ts Normal file
View File

@ -0,0 +1,5 @@
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'monitor-ai — 告警监控中心',
description: 'TLYQ 基础设施统一监控',
}

93
src/app/page.tsx Normal file
View File

@ -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>
)
}

74
src/app/services/page.tsx Normal file
View File

@ -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>
)
}

230
src/app/settings/page.tsx Normal file
View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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')
}

View File

@ -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>
)
}

84
src/components/TopBar.tsx Normal file
View File

@ -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>
)
}

8
src/lib/audit.ts Normal file
View File

@ -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)
}

7
src/lib/auth-config.ts Normal file
View File

@ -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'],
})

25
src/lib/config.ts Normal file
View File

@ -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',
}

74
src/lib/db.ts Normal file
View File

@ -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() }

236
src/lib/monitor-worker.ts Normal file
View File

@ -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 }
}
}

42
src/lib/permissions.ts Normal file
View File

@ -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
}
}

8
src/middleware.ts Normal file
View File

@ -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).*)'] }

25
tsconfig.json Normal file
View File

@ -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"]
}

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long