Compare commits
45 Commits
v2026.07.0
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
676d093568 | |
|
|
eb31132fa0 | |
|
|
90d65d3fb2 | |
|
|
b3b10cb162 | |
|
|
67e8979362 | |
|
|
8137e3aec7 | |
|
|
b6f22b1412 | |
|
|
81505b6c08 | |
|
|
8e07fbd1e2 | |
|
|
2658438c48 | |
|
|
38e97faf0c | |
|
|
022bb5ad64 | |
|
|
adbe1a877f | |
|
|
2afb98ff1d | |
|
|
8625850781 | |
|
|
5cc5e658ff | |
|
|
1bfc2577c2 | |
|
|
c876a69443 | |
|
|
b5b59f1c3b | |
|
|
ba7f181501 | |
|
|
0d83bcc19f | |
|
|
57174fd26a | |
|
|
51cfcab11e | |
|
|
2a14d3f4a2 | |
|
|
92228a3fdb | |
|
|
25365b1626 | |
|
|
de0116ddc8 | |
|
|
5cd300bee8 | |
|
|
e6712a1e99 | |
|
|
5af5ff7c0f | |
|
|
4e00279109 | |
|
|
09efbbda31 | |
|
|
e289aadd47 | |
|
|
88656b6076 | |
|
|
496ce7b1a2 | |
|
|
e5737352d5 | |
|
|
54b4639af2 | |
|
|
e47f2c18c0 | |
|
|
da6077996d | |
|
|
13e5bfdbbf | |
|
|
befefb91ac | |
|
|
d5956b6cfd | |
|
|
3504837405 | |
|
|
2682232d85 | |
|
|
e63601b737 |
41
.env.example
41
.env.example
|
|
@ -1,23 +1,20 @@
|
|||
# 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=<生成>
|
||||
|
||||
# 自签名证书
|
||||
# monitor-ai 环境变量(本地开发)
|
||||
DATABASE_PATH=./data/monitor.db
|
||||
MONITOR_MODE=dev
|
||||
JWT_SECRET=dev-jwt-secret-local
|
||||
COOKIE_DOMAIN=
|
||||
NODE_ENV=development
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
|
||||
# LDAP 配置
|
||||
LDAP_URL=ldap://localhost:3890
|
||||
LDAP_BASE_DN=dc=tlyq,dc=ai
|
||||
|
||||
# OIDC 配置
|
||||
AUTHELIA_URL=http://127.0.0.1:6180
|
||||
OIDC_CLIENT_ID=monitor-oidc
|
||||
OIDC_CLIENT_SECRET=<见 Authelia 配置>
|
||||
OIDC_REDIRECT_URI=http://127.0.0.1:6181/api/auth/callback
|
||||
|
||||
# 应急管理员
|
||||
LOCALADMIN_PASSWORD=admin123
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ data/*.db
|
|||
*.log
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
tsconfig.tsbuildinfo
|
||||
|
|
|
|||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -1,5 +1,23 @@
|
|||
# Changelog
|
||||
|
||||
## v2026.07.02
|
||||
|
||||
### 新增
|
||||
|
||||
- **用户管理页面**:用户列表、角色编辑(admin/editor/viewer),API 权限守卫
|
||||
- **角色权限管理页面**:11 个权限的开关式管理,admin/editor/viewer 三角色独立配置
|
||||
- **审计日志页面**:分页、筛选(操作类型/对象类型)、真实数据展示
|
||||
- **容器操作管理**:服务管理页面增加重启/停止/查看日志功能,通过 SSH 执行 Docker 命令,安全边界(不挂载 rw docker.sock),所有操作记录审计日志
|
||||
- **服务 CRUD 表单**:完整的添加/编辑/删除服务对话框,支持 HTTP/Docker 多维度检查配置
|
||||
- **仪表盘路由**:`/` 重定向到 `/dashboard`
|
||||
|
||||
### 优化
|
||||
|
||||
- **设计规范更新**:`tlyq-design-system.md` 新增「光标系统」章节,所有可交互非按钮/链接元素添加 `cursor-pointer`
|
||||
- **服务表单改进**:新增服务默认包含一条检查方式,至少保留一条检查不允许删除
|
||||
- **用户管理**:表格显示用户名/显示名/邮箱/角色/状态,支持内联编辑角色
|
||||
- **审计日志**:支持按操作类型筛选(登录/创建/更新/删除等),按对象类型筛选(认证/服务/告警渠道/用户/角色)
|
||||
|
||||
## v2026.07.01
|
||||
|
||||
### 新增
|
||||
|
|
|
|||
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -261,6 +261,13 @@ monitor-ai/
|
|||
| GET | `/api/status-history` | 登录 | 状态变更历史(分页,可按 service_id 筛选) |
|
||||
| GET | `/api/status` | 登录 | 所有服务实时状态汇总 |
|
||||
|
||||
### 内部 API(x-internal-key 鉴权)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/internal/users` | 返回用户列表 |
|
||||
| POST | `/api/internal/users` | OA 同步用户角色 |
|
||||
|
||||
### 管理
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
|
|
@ -359,11 +366,12 @@ NODE_TLS_REJECT_UNAUTHORIZED=0
|
|||
|
||||
| 库 | 用途 | 导入路径 |
|
||||
|------|------|------|
|
||||
| `@shared/lib/auth/jwt` | JWT 签名/验证(零依赖,Node crypto) | `import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'` |
|
||||
| `@shared/lib/auth/jwt-v2` | JWT 签名/验证(HS256,含 iss) | `import { signJwtV2, verifyJwtV2 } from '@shared/lib/auth/jwt-v2'` |
|
||||
| `@shared/lib/auth/jwt` | JWT 签名/验证(V1 兼容,无 iss) | `import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'` |
|
||||
| `@shared/lib/auth/middleware-v2` | 路由守卫工厂(V2:单 cookie 模型) | `import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'` |
|
||||
| `@shared/lib/auth/middleware` | 路由守卫工厂(V1,已废弃) | `import { createMiddleware } from '@shared/lib/auth/middleware'` |
|
||||
| `@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'` |
|
||||
|
|
|
|||
13
Dockerfile
13
Dockerfile
|
|
@ -4,10 +4,10 @@ WORKDIR /app
|
|||
|
||||
# 1. 依赖安装
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --production=false
|
||||
RUN npm ci --production=false && npm ls bcryptjs 2>/dev/null || npm install bcryptjs
|
||||
|
||||
# 2. 复制共享库
|
||||
COPY ../shared/ ./shared/
|
||||
# 2. 复制共享库(构建前由部署脚本创建 symlink: shared -> ../shared)
|
||||
COPY shared/ ./shared/
|
||||
|
||||
# 3. 复制源码并构建
|
||||
COPY . .
|
||||
|
|
@ -17,12 +17,17 @@ RUN npm run build
|
|||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN apk add --no-cache sqlite docker-cli
|
||||
COPY --from=base /app/.next/standalone ./
|
||||
COPY --from=base /app/.next/static ./.next/static
|
||||
COPY --from=base /app/scripts ./scripts
|
||||
COPY --from=base /app/src/lib ./src/lib
|
||||
COPY --from=base /app/shared ./shared
|
||||
COPY tsconfig.json ./
|
||||
COPY entrypoint.sh ./
|
||||
|
||||
RUN npm install -g tsx
|
||||
RUN npm install -g tsx tsconfig-paths
|
||||
# bcryptjs 未被 standalone 打包,需在 runner 阶段安装
|
||||
RUN npm install bcryptjs
|
||||
EXPOSE 6181
|
||||
CMD ["sh", "entrypoint.sh"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
# monitor-ai — 告警监控中心
|
||||
|
||||
tlyq.ai 统一基础设施告警监控中心,监控容器/端点健康状态,通过企业微信多 Webhook 推送告警。
|
||||
|
||||
## 功能
|
||||
|
||||
- 多维度健康检查(HTTP / Docker)
|
||||
- 多 Webhook 渠道告警推送(企业微信)
|
||||
- 告警去重、冷却机制、级别过滤、免打扰时段、抖动检测
|
||||
- OIDC SSO + LDAP 备选 + localadmin 认证
|
||||
- RBAC 权限(admin/editor/viewer)
|
||||
- 审计日志(所有写操作记录)
|
||||
- 暗色/亮色/自动主题切换
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Next.js 15 App Router(output: standalone)
|
||||
- SQLite(execFileSync + sqlite3 CLI)
|
||||
- Tailwind CSS v4 + TLYQ 设计系统
|
||||
- lucide-react 图标
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:6181
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
见 `.env.example`,本地开发使用 `.env.local`。
|
||||
|
||||
## 端口
|
||||
|
||||
| 环境 | 端口 |
|
||||
|------|------|
|
||||
| 本地开发 | 6181 |
|
||||
| 生产部署 | 6181 |
|
||||
|
||||
## 文档
|
||||
|
||||
- 设计方案:`docs/superpowers/specs/2026-06-30-monitor-ai-design.md`
|
||||
- 实施计划:`docs/superpowers/plans/2026-06-30-monitor-ai-implementation.md`
|
||||
- CLAUDE.md:项目级 AI 指导文档
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# monitor-ai 生产环境配置(模板)
|
||||
DATABASE_PATH=/app/data/monitor.db
|
||||
MONITOR_MODE=local
|
||||
JWT_SECRET=__JWT_SECRET__
|
||||
COOKIE_DOMAIN=.tlyq.ai
|
||||
NODE_ENV=production
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
LDAP_URL=ldap://lldap:3890
|
||||
AUTHELIA_URL=https://sso.tlyq.ai
|
||||
OIDC_CLIENT_ID=monitor-oidc
|
||||
OIDC_CLIENT_SECRET=__OIDC_CLIENT_SECRET__
|
||||
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
|
||||
LOCALADMIN_PASSWORD=__LOCALADMIN_PASSWORD__
|
||||
|
|
@ -4,15 +4,24 @@ services:
|
|||
build: .
|
||||
container_name: monitor-ai
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
ports:
|
||||
- "6181:6181"
|
||||
- "6181:3000"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./data:/app/data
|
||||
- ./.next:/app/.next
|
||||
env_file: .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_PATH=/app/data/monitor.db
|
||||
- MONITOR_MODE=local
|
||||
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
networks:
|
||||
- webnet
|
||||
init: true
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ set -e
|
|||
echo "[entrypoint] Starting monitor-ai..."
|
||||
|
||||
# 启动 MonitorWorker(后台,tsx 即时执行 TypeScript)
|
||||
# Worker 失败不影响 Next.js 主进程
|
||||
npx tsx scripts/monitor-worker.ts &
|
||||
WORKER_PID=$!
|
||||
WORKER_PID=$! || true
|
||||
|
||||
# 启动 Next.js standalone server
|
||||
node server.js &
|
||||
HOSTNAME=0.0.0.0 node server.js &
|
||||
NEXT_PID=$!
|
||||
|
||||
echo "[entrypoint] Worker PID: $WORKER_PID, Next.js PID: $NEXT_PID"
|
||||
|
|
@ -23,11 +24,11 @@ cleanup() {
|
|||
}
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
# 守护循环:任一进程退出则容器退出
|
||||
# 守护循环:Next.js 退出则容器退出,Worker 失败仅警告
|
||||
while true; do
|
||||
if ! kill -0 $WORKER_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Worker process exited" >&2
|
||||
exit 1
|
||||
echo "[entrypoint] Worker process exited (non-fatal)" >&2
|
||||
WORKER_PID=0
|
||||
fi
|
||||
if ! kill -0 $NEXT_PID 2>/dev/null; then
|
||||
echo "[entrypoint] Next.js process exited" >&2
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { NextConfig } from 'next'
|
|||
|
||||
const config: NextConfig = {
|
||||
output: 'standalone',
|
||||
transpilePackages: ['lucide-react'],
|
||||
transpilePackages: ['lucide-react', 'ldapts'],
|
||||
}
|
||||
|
||||
export default config
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1767,6 +1768,18 @@
|
|||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ldapts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ldapts/-/ldapts-6.0.0.tgz",
|
||||
|
|
@ -2076,6 +2089,15 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -2368,6 +2390,15 @@
|
|||
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
"integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||
|
|
@ -2410,6 +2441,20 @@
|
|||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig-paths": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
|
||||
"integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json5": "^2.2.2",
|
||||
"minimist": "^1.2.6",
|
||||
"strip-bom": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
# monitor-ai 部署脚本
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy-monitor.sh # 本地模式(默认)
|
||||
# bash deploy-monitor.sh # 部署到 txjp 服务器(默认)
|
||||
# bash deploy-monitor.sh --dev # 开发模式(本地构建)
|
||||
# bash deploy-monitor.sh --remote --host <IP> # 远程部署到指定服务器
|
||||
#
|
||||
# 首次部署自动:
|
||||
# 1. 生成 OIDC_CLIENT_SECRET (openssl rand -hex 32)
|
||||
|
|
@ -42,20 +41,9 @@ fi
|
|||
# ============================================================
|
||||
# 解析参数
|
||||
# ============================================================
|
||||
MODE="local"
|
||||
HOST=""
|
||||
MODE="remote"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--remote)
|
||||
MODE="remote"
|
||||
info "模式: 远程部署"
|
||||
shift
|
||||
;;
|
||||
--host)
|
||||
HOST="$2"
|
||||
info "目标主机: $HOST"
|
||||
shift 2
|
||||
;;
|
||||
--dev)
|
||||
MODE="dev"
|
||||
info "模式: 开发构建"
|
||||
|
|
@ -69,7 +57,7 @@ while [[ $# -gt 0 ]]; do
|
|||
done
|
||||
|
||||
# ============================================================
|
||||
# 路径配置
|
||||
# 路径配置(对齐 deploy-ai.sh,使用 SSH 别名 txjp)
|
||||
# ============================================================
|
||||
LOCAL_DIR="/Users/niuniu/programs/docker"
|
||||
MONITOR_DIR="$LOCAL_DIR/monitor-ai"
|
||||
|
|
@ -79,13 +67,6 @@ 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
|
||||
|
||||
# ============================================================
|
||||
# 打包源码
|
||||
# ============================================================
|
||||
|
|
@ -97,10 +78,10 @@ package_source() {
|
|||
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='data' \
|
||||
--exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \
|
||||
--exclude='.git' --exclude='._*' \
|
||||
shared/ monitor-ai/ || {
|
||||
shared/ monitor-ai/ docs/design/design-system/tlyq-design-system.css || {
|
||||
error "打包失败"
|
||||
return 1
|
||||
}
|
||||
|
|
@ -116,28 +97,10 @@ package_source() {
|
|||
# 首次部署:生成密钥和配置
|
||||
# ============================================================
|
||||
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
|
||||
env_exists=$(ssh txjp "test -f $REMOTE_DIR/.env && echo yes || echo no" 2>/dev/null)
|
||||
|
||||
if [[ "$env_exists" == "yes" ]]; then
|
||||
info "已存在 .env,跳过密钥生成"
|
||||
|
|
@ -151,10 +114,8 @@ first_deploy_setup() {
|
|||
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
|
||||
# 生成 pbkdf2 密码哈希(通过 Authelia 容器)
|
||||
PBKDF2_HASH=$(ssh txjp "docker exec authelia authelia crypto hash generate pbkdf2 --password '$LOCALADMIN_PASS' 2>/dev/null | grep 'Digest:' | awk '{print \$2}'" 2>/dev/null)
|
||||
|
||||
# 如果 Authelia 不可用,使用 openssl 生成备用哈希
|
||||
if [[ -z "$PBKDF2_HASH" ]]; then
|
||||
|
|
@ -164,8 +125,8 @@ first_deploy_setup() {
|
|||
fi
|
||||
|
||||
# 写入 .env 文件
|
||||
local env_content
|
||||
env_content="DATABASE_PATH=/app/data/monitor.db
|
||||
ssh txjp "cat > $REMOTE_DIR/.env << 'ENVEOF'
|
||||
DATABASE_PATH=/app/data/monitor.db
|
||||
MONITOR_MODE=local
|
||||
AUTHELIA_URL=https://sso.tlyq.ai
|
||||
OIDC_CLIENT_ID=monitor-oidc
|
||||
|
|
@ -176,15 +137,8 @@ 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
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
ENVEOF"
|
||||
else
|
||||
echo "$env_content" > "$MONITOR_DIR/.env"
|
||||
fi
|
||||
|
||||
log "密钥已生成"
|
||||
info " OIDC_CLIENT_SECRET: $OIDC_SECRET"
|
||||
|
|
@ -196,7 +150,6 @@ ENVEOF"
|
|||
# 更新 nginx 配置
|
||||
# ============================================================
|
||||
update_nginx() {
|
||||
local target="$1"
|
||||
local nginx_conf="$LOCAL_DIR/nginx-proxy-ai/conf.d/monitor-ai.conf"
|
||||
|
||||
if [[ ! -f "$nginx_conf" ]]; then
|
||||
|
|
@ -204,21 +157,15 @@ update_nginx() {
|
|||
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 重载失败"
|
||||
scp "$nginx_conf" txjp:/root/docker/nginx-proxy-ai/conf.d/monitor-ai.conf 2>/dev/null || true
|
||||
ssh txjp "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
|
||||
|
|
@ -238,13 +185,13 @@ build_on_server() {
|
|||
echo " 源码快照: ${local_md5}"
|
||||
|
||||
local prev_md5
|
||||
prev_md5=$(ssh "$target" "cat ${SNAPSHOT_FILE} 2>/dev/null" 2>/dev/null || echo "")
|
||||
prev_md5=$(ssh txjp "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"
|
||||
ssh txjp "cd $REMOTE_DIR && docker compose down && docker compose up -d"
|
||||
log "容器已重建"
|
||||
return 0
|
||||
else
|
||||
|
|
@ -256,14 +203,14 @@ build_on_server() {
|
|||
|
||||
# 3. 上传
|
||||
log "上传源码包..."
|
||||
scp /tmp/monitor-ai.tar.gz "$target:/tmp/monitor-ai.tar.gz" || {
|
||||
scp /tmp/monitor-ai.tar.gz txjp:/tmp/monitor-ai.tar.gz || {
|
||||
error "上传失败"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 4. 解压源码到 /tmp,然后 rsync 到目标目录
|
||||
log "解压源码并准备依赖..."
|
||||
ssh "$target" "\
|
||||
ssh txjp "\
|
||||
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 \
|
||||
|
|
@ -274,13 +221,17 @@ build_on_server() {
|
|||
rsync -a --delete \
|
||||
--exclude='node_modules' --exclude='.git' \
|
||||
/tmp/deploy_monitor/shared/ /root/docker/shared/ && \
|
||||
rm -rf $REMOTE_DIR/shared && \
|
||||
cp -r /root/docker/shared $REMOTE_DIR/shared && \
|
||||
mkdir -p $REMOTE_DIR/docs/design/design-system && \
|
||||
cp /tmp/deploy_monitor/docs/design/design-system/tlyq-design-system.css $REMOTE_DIR/docs/design/design-system/ 2>/dev/null || true && \
|
||||
rm -rf /tmp/deploy_monitor"
|
||||
|
||||
# 5. 首次部署:上传 docker-compose.yml 和 Dockerfile
|
||||
ssh "$target" "cd $REMOTE_DIR && docker compose up -d"
|
||||
ssh txjp "cd $REMOTE_DIR && docker compose up -d"
|
||||
|
||||
# 如果 host 上没有 node_modules,从容器内复制一份
|
||||
ssh "$target" "if [ ! -d '${REMOTE_DIR}/node_modules' ]; then
|
||||
ssh txjp "if [ ! -d '${REMOTE_DIR}/node_modules' ]; then
|
||||
echo ' 首次:复制容器内 node_modules 到主机...'
|
||||
docker cp ${CONTAINER}:/app/node_modules ${REMOTE_DIR}/node_modules
|
||||
echo ' 完成'
|
||||
|
|
@ -288,53 +239,37 @@ build_on_server() {
|
|||
|
||||
# 6. 安装可能新增的依赖
|
||||
log "安装新增依赖..."
|
||||
ssh "$target" "cd ${REMOTE_DIR} && npm install --prefer-offline 2>&1 | tail -5 || true"
|
||||
ssh txjp "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 '已清理本地环境文件'"
|
||||
ssh txjp "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. 重建容器
|
||||
# 8. 重建容器(Dockerfile 内完成构建)
|
||||
log "重建容器..."
|
||||
ssh "$target" "cd $REMOTE_DIR && docker compose up -d && docker compose restart"
|
||||
ssh txjp "cd $REMOTE_DIR && docker compose up -d --build && docker compose restart"
|
||||
|
||||
# 10. 清理构建缓存
|
||||
log "清理构建缓存..."
|
||||
ssh "$target" "docker image prune -f 2>/dev/null || true"
|
||||
ssh txjp "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")
|
||||
disk_usage=$(ssh txjp "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"
|
||||
ssh txjp "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"
|
||||
ssh txjp "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"
|
||||
ssh txjp "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"
|
||||
ssh txjp "docker builder prune -f --filter 'until=48h' 2>/dev/null || true"
|
||||
fi
|
||||
|
||||
# 11. 保存快照
|
||||
ssh "$target" "echo '$local_md5' > ${SNAPSHOT_FILE}"
|
||||
ssh txjp "echo '$local_md5' > ${SNAPSHOT_FILE}"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
|
|
@ -372,34 +307,19 @@ echo ""
|
|||
BUILD_START=$(date +%s)
|
||||
|
||||
case "$MODE" in
|
||||
local)
|
||||
# 本地模式:部署到 txjp 服务器(默认行为)
|
||||
remote)
|
||||
# 远程模式:部署到 txjp 服务器
|
||||
log "部署目标: $MONITOR_DIR → txjp:$REMOTE_DIR"
|
||||
echo ""
|
||||
|
||||
# 首次部署检查
|
||||
first_deploy_setup "$SSH_TARGET"
|
||||
first_deploy_setup
|
||||
|
||||
# 构建
|
||||
build_on_server "$SSH_TARGET"
|
||||
build_on_server
|
||||
|
||||
# 更新 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"
|
||||
update_nginx
|
||||
;;
|
||||
|
||||
dev)
|
||||
|
|
@ -421,7 +341,7 @@ if [[ "$MODE" == "dev" ]]; then
|
|||
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 "???")
|
||||
STATUS=$(ssh txjp "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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,121 @@
|
|||
// src/app/admin/audit-logs/page.tsx — 审计日志(占位)
|
||||
'use client'
|
||||
// src/app/admin/audit-logs/page.tsx — 审计日志
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RotateCw } from 'lucide-react'
|
||||
|
||||
interface AuditLog {
|
||||
id: number; username: string | null; action: string; entity_type: string
|
||||
details: string | null; ip_address: string | null; created_at: string
|
||||
}
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [action, setAction] = useState('')
|
||||
const [entityType, setEntityType] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const pageSize = 20
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (action) params.set('action', action)
|
||||
if (entityType) params.set('entity_type', entityType)
|
||||
const res = await fetch(`/api/admin/audit-logs?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLogs(data.data)
|
||||
setTotal(data.total)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [page, action, entityType])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
const renderDetails = (details: string | null) => {
|
||||
if (!details) return '—'
|
||||
try { return JSON.stringify(JSON.parse(details)).slice(0, 80) }
|
||||
catch { return details.slice(0, 80) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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>
|
||||
<p className="text-sm text-slate-400 mt-1">系统操作与事件记录</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<select value={action} onChange={e => { setAction(e.target.value); setPage(1) }}
|
||||
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
|
||||
<option value="">全部操作</option>
|
||||
<option value="login">登录</option><option value="login_failed">登录失败</option><option value="logout">登出</option>
|
||||
<option value="create_service">创建服务</option><option value="update_service">更新服务</option><option value="delete_service">删除服务</option>
|
||||
<option value="create_channel">创建渠道</option><option value="update_channel">更新渠道</option><option value="delete_channel">删除渠道</option>
|
||||
<option value="update_user">更新用户</option><option value="update_role">更新角色</option>
|
||||
</select>
|
||||
<select value={entityType} onChange={e => { setEntityType(e.target.value); setPage(1) }}
|
||||
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
|
||||
<option value="">全部对象</option>
|
||||
<option value="auth">认证</option><option value="service">服务</option><option value="alert_channel">告警渠道</option>
|
||||
<option value="user">用户</option><option value="role">角色</option>
|
||||
</select>
|
||||
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
|
||||
<RotateCw size={14} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">时间</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">用户</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">对象</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">详情</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">加载中...</td></tr>
|
||||
) : logs.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">暂无数据</td></tr>
|
||||
) : logs.map(log => (
|
||||
<tr key={log.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 text-sm text-slate-600 dark:text-slate-400 whitespace-nowrap">{log.created_at}</td>
|
||||
<td className="px-4 py-3 text-sm">{log.username || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
log.action.includes('login') ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
|
||||
log.action.includes('failed') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
|
||||
log.action.includes('create') ? 'bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400' :
|
||||
log.action.includes('delete') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
|
||||
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
|
||||
}`}>{log.action}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{log.entity_type}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-500 font-mono text-xs">{renderDetails(log.details)}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{log.ip_address || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
|
||||
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40">上一页</button>
|
||||
<span className="text-sm text-slate-500">第 {page}/{totalPages} 页 (共 {total} 条)</span>
|
||||
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}
|
||||
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40">下一页</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
// src/app/admin/layout.tsx — V2 admin 路径鉴权(Layer 2:独立验签 + DB role 检查)
|
||||
// V2 middleware 不检查 role,由 layout/API 层自行鉴权
|
||||
import { cookies } from 'next/headers'
|
||||
import { verifyJwtV2, extractIss } from '@shared/lib/auth/jwt-v2'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQueryParams } from '@/lib/db'
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('tlyq_session')?.value
|
||||
|
||||
if (!token) {
|
||||
return <Forbidden />
|
||||
}
|
||||
|
||||
// 提取 iss → 自引用验签
|
||||
const iss = extractIss(token)
|
||||
if (!iss) {
|
||||
return <Forbidden />
|
||||
}
|
||||
|
||||
const jwtSecret = authConfig.jwtSecret
|
||||
const payload = verifyJwtV2(token, jwtSecret, iss)
|
||||
if (!payload) {
|
||||
return <Forbidden />
|
||||
}
|
||||
|
||||
// 从本地 DB 查询 role
|
||||
const rows = dbQueryParams<{ role: string }>(
|
||||
'SELECT role FROM users WHERE username = ? AND is_active = 1',
|
||||
[payload.username]
|
||||
)
|
||||
const role = rows[0]?.role || 'viewer'
|
||||
|
||||
if (role !== 'admin') {
|
||||
return <Forbidden />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function Forbidden() {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '60vh' }}>
|
||||
<p style={{ color: '#999', fontSize: 18 }}>Forbidden</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,130 @@
|
|||
// src/app/admin/roles/page.tsx — 角色权限管理(占位)
|
||||
'use client'
|
||||
// src/app/admin/roles/page.tsx — 角色权限管理
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Shield, RotateCw, Save } from 'lucide-react'
|
||||
|
||||
interface RoleData { role: string; permissions: { key: string; enabled: boolean }[] }
|
||||
|
||||
export default function RolesPage() {
|
||||
const [roles, setRoles] = useState<RoleData[]>([])
|
||||
const [allPermissions, setAllPermissions] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [perms, setPerms] = useState<string[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/admin/roles')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRoles(data.roles)
|
||||
setAllPermissions(data.allPermissions)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const showToast = (type: 'ok' | 'err', msg: string) => {
|
||||
setToast({ type, msg })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const startEdit = (role: RoleData) => {
|
||||
setEditing(role.role)
|
||||
setPerms(role.permissions.filter(p => p.enabled).map(p => p.key))
|
||||
}
|
||||
|
||||
const togglePerm = (key: string) => {
|
||||
setPerms(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin/roles', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: editing, permissions: perms }),
|
||||
})
|
||||
if (res.ok) { showToast('ok', `${editing} 角色权限已更新`); setEditing(null); load() }
|
||||
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
|
||||
} catch { showToast('err', '网络错误') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const permLabels: Record<string, string> = {
|
||||
'dashboard:view': '查看仪表盘', 'services:view': '查看服务', 'services:manage': '管理服务',
|
||||
'alerts:view': '查看告警', 'alerts:manage': '管理告警渠道', 'status_history:view': '查看状态历史',
|
||||
'status_history:stats': '可用率统计', 'audit:view': '查看审计', 'users:view': '查看用户',
|
||||
'users:manage': '管理用户', 'roles:manage': '管理角色',
|
||||
}
|
||||
|
||||
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>
|
||||
{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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">角色权限管理</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">admin / editor / viewer</p>
|
||||
</div>
|
||||
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
|
||||
<RotateCw size={14} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-400">加载中...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{roles.map(role => (
|
||||
<div key={role.role} 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">
|
||||
<h2 className="font-semibold capitalize flex items-center gap-2">
|
||||
<Shield size={18} className={role.role === 'admin' ? 'text-indigo-600' : role.role === 'editor' ? 'text-blue-600' : 'text-slate-400'} />
|
||||
{role.role}
|
||||
</h2>
|
||||
{editing === role.role ? (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-3 py-1.5 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
<Save size={14} /> {saving ? '保存中' : '保存'}
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)} className="px-3 py-1.5 text-sm border rounded-lg">取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => startEdit(role)} className="px-3 py-1.5 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">编辑</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{role.role === 'admin' ? (
|
||||
<span className="text-sm text-slate-400">admin 角色拥有全部 {allPermissions.length} 个权限</span>
|
||||
) : editing === role.role ? (
|
||||
allPermissions.map(p => (
|
||||
<label key={p} className="flex items-center gap-2 text-sm py-1 cursor-pointer">
|
||||
<input type="checkbox" checked={perms.includes(p)} onChange={() => togglePerm(p)}
|
||||
className="rounded border-slate-300 text-indigo-600" />
|
||||
{permLabels[p] || p}
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
role.permissions.filter(p => p.enabled).map(p => (
|
||||
<span key={p.key} className="px-2 py-1 rounded bg-slate-50 dark:bg-slate-800 text-xs text-slate-600 dark:text-slate-400">
|
||||
{permLabels[p.key] || p.key}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,217 @@
|
|||
// src/app/admin/users/page.tsx — 用户管理(占位,Phase 3 完成)
|
||||
'use client'
|
||||
// src/app/admin/users/page.tsx — 用户管理(含密码修改)
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Users, RotateCw, X } from 'lucide-react'
|
||||
|
||||
interface User {
|
||||
id: number; username: string; display_name: string | null; email: string | null
|
||||
role: string; is_active: number; last_login_at: string | null
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: '', password: '', password_confirm: '' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/admin/users')
|
||||
if (res.ok) setUsers(await res.json())
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const showToast = (type: 'ok' | 'err', msg: string) => {
|
||||
setToast({ type, msg })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const openEdit = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setEditForm({
|
||||
display_name: user.display_name || '',
|
||||
email: user.email || '',
|
||||
role: user.role,
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
})
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (editForm.password && editForm.password !== editForm.password_confirm) {
|
||||
setError('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
if (editForm.password && editForm.password.length < 8) {
|
||||
setError('密码至少 8 位')
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
display_name: editForm.display_name,
|
||||
email: editForm.email,
|
||||
}
|
||||
if (editForm.password) body.password = editForm.password
|
||||
|
||||
const res = await fetch(`/api/admin/users/${editingUser!.id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (res.ok) {
|
||||
showToast('ok', '用户已更新')
|
||||
setEditingUser(null)
|
||||
load()
|
||||
} else {
|
||||
const d = await res.json()
|
||||
setError(d.error || '保存失败')
|
||||
}
|
||||
} catch { setError('网络错误') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
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>
|
||||
{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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]">用户管理</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">管理用户角色和密码</p>
|
||||
</div>
|
||||
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
|
||||
<RotateCw size={14} /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">用户名</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">显示名</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">邮箱</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">角色</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">状态</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">加载中...</td></tr>
|
||||
) : users.map(user => (
|
||||
<tr key={user.id} className="border-b border-slate-100 dark:border-slate-800">
|
||||
<td className="px-4 py-3 text-sm font-medium">{user.username}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">{user.display_name || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-400">{user.email || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin' ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400' :
|
||||
user.role === 'editor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
|
||||
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
|
||||
}`}>{user.role}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{user.is_active ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button onClick={() => openEdit(user)}
|
||||
className="px-3 py-1 text-xs border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">编辑</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">编辑用户: {editingUser.username}</h2>
|
||||
<button onClick={() => setEditingUser(null)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-800">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">{error}</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 type="text" value={editForm.display_name}
|
||||
onChange={e => setEditForm(p => ({ ...p, display_name: 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-slate-900 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">邮箱</label>
|
||||
<input type="email" value={editForm.email}
|
||||
onChange={e => setEditForm(p => ({ ...p, email: 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-slate-900 dark:text-white text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">角色</label>
|
||||
<select value={editForm.role}
|
||||
onChange={e => setEditForm(p => ({ ...p, role: e.target.value }))}
|
||||
disabled={editingUser.username === 'admin' || editingUser.username === 'localadmin'}
|
||||
className="w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-900 dark:text-white text-sm disabled:opacity-50">
|
||||
<option value="admin">admin</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
{(editingUser.username === 'admin' || editingUser.username === 'localadmin') && (
|
||||
<p className="text-xs text-slate-400 mt-1">系统保留用户,角色不可修改</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">修改密码(留空不修改)</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">新密码</label>
|
||||
<input type="password" value={editForm.password} placeholder="留空不修改"
|
||||
onChange={e => setEditForm(p => ({ ...p, password: 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-slate-900 dark:text-white text-sm" />
|
||||
</div>
|
||||
{editForm.password && (
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">确认新密码</label>
|
||||
<input type="password" value={editForm.password_confirm}
|
||||
onChange={e => setEditForm(p => ({ ...p, password_confirm: 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-slate-900 dark:text-white text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={() => setEditingUser(null)} disabled={saving}
|
||||
className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 disabled:opacity-50">取消</button>
|
||||
<button onClick={handleSave} disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
// GET /api/admin/audit-logs — 审计日志列表(admin)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQueryParams, dbExec } from '@/lib/db'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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(getRole(payload), 'audit:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || ''
|
||||
const entityType = searchParams.get('entity_type') || ''
|
||||
const username = searchParams.get('username') || ''
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20')))
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
let where = 'WHERE 1=1'
|
||||
const params: unknown[] = []
|
||||
if (action) {
|
||||
where += ' AND action = ?'
|
||||
params.push(action)
|
||||
}
|
||||
if (entityType) {
|
||||
where += ' AND entity_type = ?'
|
||||
params.push(entityType)
|
||||
}
|
||||
if (username) {
|
||||
where += ' AND username = ?'
|
||||
params.push(username)
|
||||
}
|
||||
|
||||
const countRow = dbQueryParams<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM audit_logs ${where}`, params)
|
||||
const total = countRow[0]?.cnt || 0
|
||||
const rows = dbQueryParams(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset])
|
||||
|
||||
return NextResponse.json({ data: rows, total, page, pageSize })
|
||||
}
|
||||
|
||||
// DELETE /api/admin/audit-logs — 清理过期日志(admin)
|
||||
export async function DELETE(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(getRole(payload), 'audit:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const days = parseInt(searchParams.get('days') || '180')
|
||||
if (days < 30 || days > 365) {
|
||||
return NextResponse.json({ error: '保留天数需在 30-365 之间' }, { status: 400 })
|
||||
}
|
||||
|
||||
dbExec(`DELETE FROM audit_logs WHERE created_at < datetime('now', '-' || ? || ' days', '+8 hours')`, [days])
|
||||
return NextResponse.json({ success: true, message: `已清理 ${days} 天前的日志` })
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
// GET/PUT /api/admin/roles — 角色权限管理(admin)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQueryParams, dbExec } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission, PERMISSIONS } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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(getRole(payload), 'roles:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// 返回所有权限定义 + 各角色的权限映射
|
||||
const rolePermissions = dbQueryParams<{ role: string; permission_key: string }>('SELECT role, permission_key FROM role_permissions', [])
|
||||
const roles = ['admin', 'editor', 'viewer']
|
||||
const permKeys = PERMISSIONS.map(p => p.key)
|
||||
|
||||
const result = roles.map(role => ({
|
||||
role,
|
||||
permissions: PERMISSIONS.map(p => ({
|
||||
key: p.key,
|
||||
enabled: role === 'admin' || rolePermissions.some(rp => rp.role === role && rp.permission_key === p.key),
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ roles: result, allPermissions: permKeys })
|
||||
}
|
||||
|
||||
export async function PUT(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(getRole(payload), 'roles:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { role, permissions } = body as { role: string; permissions: string[] }
|
||||
|
||||
if (!role || !permissions || !Array.isArray(permissions)) {
|
||||
return NextResponse.json({ error: '参数错误' }, { status: 400 })
|
||||
}
|
||||
|
||||
// role 白名单校验
|
||||
const validRoles = ['admin', 'editor', 'viewer']
|
||||
if (!validRoles.includes(role)) {
|
||||
return NextResponse.json({ error: `无效角色,允许值: ${validRoles.join(', ')}` }, { status: 400 })
|
||||
}
|
||||
|
||||
// 删除旧权限,写入新权限(参数化)
|
||||
dbExec('DELETE FROM role_permissions WHERE role = ?', [role])
|
||||
for (const perm of permissions) {
|
||||
dbExec('INSERT OR IGNORE INTO role_permissions (role, permission_key) VALUES (?, ?)', [role, perm])
|
||||
}
|
||||
|
||||
writeAuditLog({
|
||||
userId: Number(payload.sub) || null,
|
||||
username: String(payload.username || ''),
|
||||
action: 'update_role',
|
||||
entityType: 'role',
|
||||
details: { role, permissions },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
// PUT /api/admin/users/[id] — 修改用户(角色、密码等)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQueryParams, dbExec } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
export async function PUT(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(getRole(payload), 'users:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const userId = Number(id)
|
||||
if (!userId || isNaN(userId)) {
|
||||
return NextResponse.json({ error: '无效的用户 ID' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { role, display_name, email, password } = body
|
||||
|
||||
// 检查用户是否存在(参数化查询)
|
||||
const existing = dbQueryParams<{ id: number; username: string }>(
|
||||
'SELECT id, username FROM users WHERE id = ?', [userId]
|
||||
)
|
||||
if (existing.length === 0) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 })
|
||||
}
|
||||
|
||||
const setClauses: string[] = []
|
||||
const values: unknown[] = []
|
||||
const details: Record<string, unknown> = {}
|
||||
|
||||
if (role !== undefined) {
|
||||
// 禁止修改系统保留用户的角色
|
||||
if (existing[0].username === 'admin' || existing[0].username === 'localadmin') {
|
||||
return NextResponse.json({ error: '不能修改系统保留用户的角色' }, { status: 400 })
|
||||
}
|
||||
// role 白名单校验
|
||||
const validRoles = ['admin', 'editor', 'viewer']
|
||||
if (!validRoles.includes(role)) {
|
||||
return NextResponse.json({ error: `无效角色,允许值: ${validRoles.join(', ')}` }, { status: 400 })
|
||||
}
|
||||
setClauses.push('role = ?')
|
||||
values.push(role)
|
||||
details.role = role
|
||||
}
|
||||
if (display_name !== undefined) {
|
||||
setClauses.push('display_name = ?')
|
||||
values.push(String(display_name))
|
||||
details.display_name = display_name
|
||||
}
|
||||
if (email !== undefined) {
|
||||
setClauses.push('email = ?')
|
||||
values.push(String(email) || null)
|
||||
details.email = email
|
||||
}
|
||||
if (password) {
|
||||
// 服务端密码长度验证
|
||||
if (password.length < 8 || password.length > 128) {
|
||||
return NextResponse.json({ error: '密码长度需在 8-128 位之间' }, { status: 400 })
|
||||
}
|
||||
const hash = await bcrypt.hash(password, 12)
|
||||
setClauses.push('password_hash = ?')
|
||||
values.push(hash)
|
||||
details.password = '***'
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
return NextResponse.json({ error: '无可更新字段' }, { status: 400 })
|
||||
}
|
||||
setClauses.push("updated_at = datetime('now', '+8 hours')")
|
||||
values.push(userId)
|
||||
|
||||
dbExec(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`, values)
|
||||
|
||||
writeAuditLog({
|
||||
userId: Number(payload.sub) || null,
|
||||
username: String(payload.username || ''),
|
||||
action: 'update_user',
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
details,
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// GET /api/admin/users — 用户列表(admin)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQueryParams } from '@/lib/db'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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(getRole(payload), 'users:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const username = searchParams.get('username') || ''
|
||||
const role = searchParams.get('role') || ''
|
||||
|
||||
let where = 'WHERE 1=1'
|
||||
const params: unknown[] = []
|
||||
if (username) {
|
||||
where += ' AND username LIKE ?'
|
||||
params.push(`%${username}%`)
|
||||
}
|
||||
if (role) {
|
||||
where += ' AND role = ?'
|
||||
params.push(role)
|
||||
}
|
||||
|
||||
const rows = dbQueryParams(`SELECT id, username, display_name, email, role, is_active, last_login_at FROM users ${where} ORDER BY id`, params)
|
||||
return NextResponse.json(rows)
|
||||
}
|
||||
|
|
@ -3,12 +3,13 @@ import { NextRequest, NextResponse } from 'next/server'
|
|||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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 })
|
||||
if (!payload || !hasPermission(getRole(payload), 'dashboard:view')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
return NextResponse.json({ status: 'running', uptime: process.uptime() })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import { authConfig } from '@/lib/auth-config'
|
|||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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
|
||||
if (!payload || !hasPermission(getRole(payload), 'alerts:manage')) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import { authConfig } from '@/lib/auth-config'
|
|||
import { dbQuery } from '@/lib/db'
|
||||
import { WeChatPusher } from '@shared/lib/wechat/wechat-pusher'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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')) {
|
||||
if (!payload || !hasPermission(getRole(payload), 'alerts:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const { id } = await params
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import { authConfig } from '@/lib/auth-config'
|
|||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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')) {
|
||||
if (!payload || !hasPermission(getRole(payload), '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')
|
||||
|
|
@ -21,7 +22,7 @@ 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')) {
|
||||
if (!payload || !hasPermission(getRole(payload), 'alerts:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const body = await request.json()
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import { verifyJwt } from '@shared/lib/auth/jwt'
|
|||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery } from '@/lib/db'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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')) {
|
||||
if (!payload || !hasPermission(getRole(payload), 'alerts:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
|
|
|||
|
|
@ -1,97 +1,53 @@
|
|||
// GET /api/auth/callback — OIDC callback 处理
|
||||
// GET /api/auth/callback — OIDC callback(V2:使用 shared handleOidcCallback 工厂)
|
||||
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 { handleOidcCallback } from '@shared/lib/auth/handle-callback'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
|
||||
import { dbQueryParams, dbExec } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
|
||||
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')
|
||||
return handleOidcCallback(request, {
|
||||
oidc: {
|
||||
autheliaUrl: authConfig.autheliaUrl,
|
||||
clientId: authConfig.oidcClientId,
|
||||
clientSecret: authConfig.oidcClientSecret,
|
||||
redirectUri: authConfig.oidcRedirectUri,
|
||||
},
|
||||
jwtSecret: authConfig.jwtSecret,
|
||||
cookieDomain: authConfig.cookieDomain,
|
||||
|
||||
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)}`)
|
||||
const rows = dbQueryParams<{ id: number; role: string }>(
|
||||
'SELECT id, role FROM users WHERE username = ? AND is_active = 1', [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`)
|
||||
dbExec(
|
||||
'INSERT INTO users (username, display_name, email, role) VALUES (?, ?, ?, ?)',
|
||||
[username, displayName, email, 'viewer']
|
||||
)
|
||||
const row = dbQueryParams<{ 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)}`)
|
||||
dbExec(
|
||||
"UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?",
|
||||
[displayName, email, username]
|
||||
)
|
||||
},
|
||||
}, userinfo)
|
||||
|
||||
// 签发 JWT
|
||||
const token = signJwt({
|
||||
secret: authConfig.jwtSecret,
|
||||
payload: { username: userinfo.preferred_username, displayName: userinfo.name, role: user.role },
|
||||
onAuditLog: (userId, username, req) => {
|
||||
writeAuditLog({
|
||||
userId,
|
||||
username,
|
||||
action: 'login',
|
||||
entityType: 'auth',
|
||||
details: { method: 'oidc' },
|
||||
ipAddress: req.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,16 @@
|
|||
// GET /api/auth/login/oidc — OIDC SSO 重定向
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generatePkce, generateState, buildAuthorizeUrl } from '@shared/lib/auth/oidc'
|
||||
// GET /api/auth/login/oidc — OIDC SSO 重定向(V2:使用 shared handleOidcLogin 工厂)
|
||||
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
|
||||
export async function GET() {
|
||||
const { codeVerifier, codeChallenge } = generatePkce()
|
||||
const state = generateState()
|
||||
const nonce = generateState()
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const switchUser = url.searchParams.get('switch') === '1'
|
||||
|
||||
const url = buildAuthorizeUrl(
|
||||
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
|
||||
{ codeChallenge, state, nonce },
|
||||
)
|
||||
|
||||
const response = NextResponse.redirect(url)
|
||||
|
||||
// 存储 PKCE 参数到 httpOnly cookie(5 分钟过期)
|
||||
const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 300, path: '/' }
|
||||
response.cookies.set('oidc_code_verifier', codeVerifier, cookieOpts)
|
||||
response.cookies.set('oidc_state', state, cookieOpts)
|
||||
response.cookies.set('oidc_nonce', nonce, cookieOpts)
|
||||
|
||||
return response
|
||||
return handleOidcLogin({
|
||||
autheliaUrl: authConfig.autheliaUrl,
|
||||
clientId: authConfig.oidcClientId,
|
||||
clientSecret: authConfig.oidcClientSecret,
|
||||
redirectUri: authConfig.oidcRedirectUri,
|
||||
switchUser,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
// POST /api/auth/login — LDAP 本地登录(回退通道)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { signJwt } from '@shared/lib/auth/jwt'
|
||||
import { signJwtV2 } from '@shared/lib/auth/jwt-v2'
|
||||
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'
|
||||
import { dbQuery, dbQueryParams } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { checkRateLimit, resetRateLimit } from '@/lib/rate-limit'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let username: string, password: string
|
||||
|
|
@ -22,35 +23,47 @@ export async function POST(request: NextRequest) {
|
|||
return NextResponse.json({ error: '用户名和密码不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 速率限制(5次/15分钟)
|
||||
const ip = request.headers.get('x-forwarded-for') || '127.0.0.1'
|
||||
const rateLimitKey = `login:${username}:${ip}`
|
||||
const { allowed, retryAfterMs } = checkRateLimit(rateLimitKey)
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: `登录尝试过于频繁,请 ${Math.ceil(retryAfterMs / 60000)} 分钟后重试` }, { status: 429 })
|
||||
}
|
||||
|
||||
// localadmin 密码验证(查询数据库存储的密码)
|
||||
if (username === 'localadmin') {
|
||||
const users = dbQuery<{ password_hash: string; role: string }>(`SELECT password_hash, role FROM users WHERE username = 'localadmin'`)
|
||||
const users = dbQuery<{ password_hash: string; role: string; display_name: string | null }>(`SELECT password_hash, role, display_name 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 }, {
|
||||
const localadminUser = users[0]
|
||||
// 与数据库存储的 bcrypt 哈希比较(异步避免阻塞事件循环)
|
||||
if (!await bcrypt.compare(password, localadminUser.password_hash)) {
|
||||
writeAuditLog({
|
||||
username, action: 'login_failed', entityType: 'auth',
|
||||
details: { method: 'localadmin', reason: 'wrong_password' },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
ipAddress: ip,
|
||||
})
|
||||
return NextResponse.json({ error: '密码错误' }, { status: 401 })
|
||||
}
|
||||
|
||||
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username: 'localadmin', role: 'admin', displayName: 'localadmin' } })
|
||||
resetRateLimit(rateLimitKey)
|
||||
const displayName = localadminUser.display_name || 'localadmin'
|
||||
const role = localadminUser.role || 'admin'
|
||||
const token = signJwtV2({ secret: authConfig.jwtSecret, iss: 'monitor.tlyq.ai', payload: { username: 'localadmin', displayName } })
|
||||
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
writeAuditLog({
|
||||
username, action: 'login', entityType: 'auth',
|
||||
details: { method: 'localadmin' },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
ipAddress: ip,
|
||||
})
|
||||
|
||||
const response = NextResponse.json({
|
||||
user: { username: 'localadmin', role: 'admin', displayName: 'localadmin' },
|
||||
user: { username: 'localadmin', role, displayName },
|
||||
})
|
||||
response.cookies.set('tlyq_session', token, {
|
||||
httpOnly: true, secure: false, sameSite: 'lax', path: '/', maxAge: 604800,
|
||||
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 604800,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
|
@ -62,30 +75,32 @@ export async function POST(request: NextRequest) {
|
|||
)
|
||||
|
||||
if (!result.success) {
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
writeAuditLog({
|
||||
username, action: 'login_failed', entityType: 'auth',
|
||||
details: { method: 'ldap', reason: result.error },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
ipAddress: ip,
|
||||
})
|
||||
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
|
||||
}
|
||||
|
||||
resetRateLimit(rateLimitKey)
|
||||
|
||||
// 签发 JWT
|
||||
const users = dbQuery(`SELECT role FROM users WHERE username = ${escapeSql(username)}`)
|
||||
const users = dbQueryParams<{ role: string }>(`SELECT role FROM users WHERE username = ?`, [username])
|
||||
const role = users.length > 0 ? users[0].role as string : 'viewer'
|
||||
|
||||
writeAuditLog({ exec: dbExec }, {
|
||||
writeAuditLog({
|
||||
username, action: 'login', entityType: 'auth',
|
||||
details: { method: 'ldap', role },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
ipAddress: ip,
|
||||
})
|
||||
|
||||
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username, role, displayName: result.displayName || username } })
|
||||
const token = signJwtV2({ secret: authConfig.jwtSecret, iss: 'monitor.tlyq.ai', payload: { username, 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,
|
||||
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 604800,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,30 @@
|
|||
// POST /api/auth/logout — 退出登录
|
||||
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login)
|
||||
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 })
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||
|
||||
/** 从 OIDC_REDIRECT_URI 提取 site URL(不可信请求头,见 LESSONS-LEARNED #51) */
|
||||
function getSiteUrl(): string {
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI || ''
|
||||
try { const u = new URL(redirectUri); return `${u.protocol}//${u.host}` } catch { /* fallthrough */ }
|
||||
return process.env.NEXT_PUBLIC_SITE_URL || 'https://monitor.tlyq.ai'
|
||||
}
|
||||
|
||||
/** 清除 tlyq_session + session + oidc_id_token cookie → 302 跳转 /login */
|
||||
function logoutResponse(): NextResponse {
|
||||
const response = NextResponse.redirect(new URL('/login', getSiteUrl()))
|
||||
response.cookies.set('tlyq_session', '', {
|
||||
httpOnly: true, secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
||||
})
|
||||
response.cookies.set('session', '', { path: '/', maxAge: 0 })
|
||||
response.cookies.set('oidc_id_token', '', {
|
||||
httpOnly: true, secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax', domain: cookieDomain, path: '/', maxAge: 0,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
export async function GET() { return logoutResponse() }
|
||||
export async function POST() { return logoutResponse() }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// GET /api/auth/me — 当前用户信息
|
||||
// GET /api/auth/me — 当前用户信息(V2:JWT 不含 role,从本地 DB 查询)
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { verifyJwtV2, extractIss } from '@shared/lib/auth/jwt-v2'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQueryParams } from '@/lib/db'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.cookies.get('tlyq_session')?.value
|
||||
|
|
@ -9,16 +10,24 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
// Layer 3 defense-in-depth:提取 iss → 自引用验签
|
||||
const iss = extractIss(token) || '*'
|
||||
const payload = verifyJwtV2(token, authConfig.jwtSecret, iss)
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Invalid token' }, { status: 401 })
|
||||
}
|
||||
|
||||
const username = payload.username as string
|
||||
|
||||
// 从本地 DB 查询角色(V2 JWT 不含 role,认证与鉴权分离)
|
||||
const rows = dbQueryParams<{ role: string; display_name: string }>(
|
||||
'SELECT role, display_name FROM users WHERE username = ? AND is_active = 1',
|
||||
[username]
|
||||
)
|
||||
const role = rows[0]?.role || 'viewer'
|
||||
const displayName = rows[0]?.display_name || (payload.displayName as string) || username
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
username: payload.username,
|
||||
display_name: payload.displayName || payload.username,
|
||||
role: payload.role || 'viewer',
|
||||
},
|
||||
user: { username, display_name: displayName, role },
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// GET /api/health — monitor-ai 自身健康检查
|
||||
export async function GET() {
|
||||
return Response.json({ status: 'OK', timestamp: new Date().toISOString() })
|
||||
const d = new Date()
|
||||
const timestamp = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}+08:00`
|
||||
return Response.json({ status: 'OK', timestamp })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
// GET /api/internal/roles — 供 OA 查询 monitor 支持的角色列表
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ROLE_DEFAULT_PERMISSIONS, PERMISSIONS } from '@/lib/permissions'
|
||||
|
||||
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const key = request.headers.get('x-internal-key')
|
||||
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS).map(name => ({
|
||||
name,
|
||||
permissions: ROLE_DEFAULT_PERMISSIONS[name].map(k => PERMISSIONS.find(p => p.key === k)?.name || k),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ roles })
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
// GET /api/internal/users — 供 OA 查询 monitor 用户列表
|
||||
// POST /api/internal/users — 供 OA 同步用户角色
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { execFileSync } from 'child_process'
|
||||
|
||||
const INTERNAL_KEY = process.env.INTERNAL_API_KEY || 'oa-internal-key-tlyq-2026'
|
||||
const DB_PATH = process.env.DATABASE_PATH || '/app/data/monitor.db'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const key = request.headers.get('x-internal-key')
|
||||
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
try {
|
||||
const out = execFileSync('sqlite3', [DB_PATH, 'SELECT username, display_name, role FROM users WHERE is_active = 1 ORDER BY username'], { timeout: 3000, encoding: 'utf8' }).trim()
|
||||
const users = out.split('\n').filter(Boolean).map(line => {
|
||||
const [username, display_name, role] = line.split('|')
|
||||
return { username, display_name: display_name || username, role }
|
||||
})
|
||||
return NextResponse.json({ users })
|
||||
} catch {
|
||||
return NextResponse.json({ users: [] })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const key = request.headers.get('x-internal-key')
|
||||
if (key !== INTERNAL_KEY) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const body = await request.json()
|
||||
const { username, displayName, role } = body
|
||||
if (!username) {
|
||||
return NextResponse.json({ error: 'username 必填' }, { status: 400 })
|
||||
}
|
||||
const VALID_ROLES = ['admin', 'editor', 'viewer']
|
||||
const safeRole = VALID_ROLES.includes(role) ? role : 'viewer'
|
||||
|
||||
try {
|
||||
execFileSync('sqlite3', [DB_PATH], {
|
||||
input: `INSERT INTO users (username, display_name, role, is_active, created_at, updated_at)
|
||||
VALUES ('${username.replace(/'/g, "''")}', '${(displayName || username).replace(/'/g, "''")}', '${safeRole}', 1,
|
||||
datetime('now', '+8 hours'), datetime('now', '+8 hours'))
|
||||
ON CONFLICT(username) DO UPDATE SET role = '${safeRole}',
|
||||
display_name = '${(displayName || username).replace(/'/g, "''")}',
|
||||
updated_at = datetime('now', '+8 hours');`,
|
||||
timeout: 3000,
|
||||
})
|
||||
return NextResponse.json({ success: true })
|
||||
} catch {
|
||||
return NextResponse.json({ error: '同步失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -5,13 +5,14 @@ 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'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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')) {
|
||||
if (!hasPermission(getRole(payload), 'services:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
// POST /api/services/[id]/ops — 容器操作(重启/停止/查看日志)
|
||||
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 { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
import { containerOp, type ContainerAction } from '@/lib/container-ops'
|
||||
|
||||
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(getRole(payload), 'services:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json() as { action: ContainerAction; containerName?: string; lines?: number }
|
||||
const { action, containerName, lines } = body
|
||||
|
||||
if (!action || !['restart', 'stop', 'start', 'logs'].includes(action)) {
|
||||
return NextResponse.json({ error: '无效操作' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 获取服务信息,确定容器名
|
||||
let targetContainer = containerName
|
||||
if (!targetContainer) {
|
||||
const services = dbQuery<{ name: string; checks: string }>(`SELECT name, checks FROM services WHERE id = ${Number(id)}`)
|
||||
if (services.length === 0) {
|
||||
return NextResponse.json({ error: '服务不存在' }, { status: 404 })
|
||||
}
|
||||
// 从 checks JSON 中提取 Docker 容器名
|
||||
try {
|
||||
const checks = JSON.parse(services[0].checks || '[]')
|
||||
const dockerCheck = checks.find((c: Record<string, unknown>) => c.type === 'docker')
|
||||
if (dockerCheck?.containerName) {
|
||||
targetContainer = String(dockerCheck.containerName)
|
||||
} else {
|
||||
return NextResponse.json({ error: '该服务未配置 Docker 检查,无法操作' }, { status: 400 })
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: '服务配置解析失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const result = await containerOp(targetContainer, action, lines || 50)
|
||||
|
||||
// 审计日志
|
||||
writeAuditLog({
|
||||
userId: Number(payload.sub) || null,
|
||||
username: String(payload.username || ''),
|
||||
action: `container_${action}`,
|
||||
entityType: 'service',
|
||||
entityId: Number(id),
|
||||
details: { containerName: targetContainer, action, success: result.success, output: result.output?.slice(0, 200) },
|
||||
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
}
|
||||
|
|
@ -5,13 +5,14 @@ import { authConfig } from '@/lib/auth-config'
|
|||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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
|
||||
if (!hasPermission(getRole(payload), 'services:manage')) return null
|
||||
return payload
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,17 @@ import { authConfig } from '@/lib/auth-config'
|
|||
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
|
||||
import { writeAuditLog } from '@/lib/audit'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!hasPermission(getRole(payload), 'services:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
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 || '[]')) }))
|
||||
|
|
@ -19,7 +28,7 @@ export async function POST(request: NextRequest) {
|
|||
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')) {
|
||||
if (!hasPermission(getRole(payload), 'services:manage')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
// GET /api/status-history — 状态变更列表
|
||||
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'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
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) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!hasPermission(getRole(payload), 'status_history:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = Number(searchParams.get('page')) || 1
|
||||
const limit = 20
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
// GET /api/status — 所有服务实时状态摘要
|
||||
import { NextResponse } from 'next/server'
|
||||
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'
|
||||
import { getRole } from '@/lib/get-role'
|
||||
|
||||
export async function GET() {
|
||||
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) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
if (!hasPermission(getRole(payload), 'services:view')) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
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() })
|
||||
const d = new Date()
|
||||
const timestamp = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:${String(d.getSeconds()).padStart(2,'0')}+08:00`
|
||||
return NextResponse.json({ services, counts, timestamp })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export default function ClientLayout({ children }: { children: React.ReactNode }
|
|||
return (
|
||||
<ThemeProvider>
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
<Sidebar />
|
||||
<Sidebar userRole={user?.role} />
|
||||
<TopBar user={user} />
|
||||
<main className="ml-60 pt-14 min-h-screen">
|
||||
<div className="p-6">{children}</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
'use client'
|
||||
// src/app/dashboard/page.tsx — 仪表盘首页(四层结构)
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function DashboardPage() {
|
||||
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 cursor-pointer ${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>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
@source "../../shared";
|
||||
@source "../../src";
|
||||
/* TLYQ 设计系统 CSS 变量 */
|
||||
@import '../../../docs/design/design-system/tlyq-design-system.css' layer(base);
|
||||
@import './tlyq-design-system.css' layer(base);
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
import LoginPage from '@shared/ui/login-page'
|
||||
|
||||
export default function Page() {
|
||||
return <LoginPage siteName="告警监控中心" />
|
||||
return <LoginPage siteName="告警监控中心" redirectPath="/dashboard" />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,93 +1,5 @@
|
|||
'use client'
|
||||
// src/app/page.tsx — 仪表盘首页(四层结构)
|
||||
import { useState, useEffect } from 'react'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
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>
|
||||
)
|
||||
redirect('/dashboard')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,111 @@
|
|||
'use client'
|
||||
// src/app/services/page.tsx — 服务管理页面
|
||||
// src/app/services/page.tsx — 服务管理页面(CRUD + 容器操作)
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Server, Plus, Edit, Trash2, Eye } from 'lucide-react'
|
||||
import { Plus, RotateCw, Square, ScrollText, X, Save, Trash2, Edit3 } 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 }
|
||||
interface Check { type: string; containerName?: string; url?: string }
|
||||
interface Service { id: number; name: string; category: string; alert_level: string; checks: Check[]; check_interval: number; enabled: number; current_status: string }
|
||||
|
||||
const emptyService = { name: '', category: 'endpoint', alert_level: 'warning', checks: [{ type: 'http', url: '' }] as Check[], check_interval: 30, check_timeout: 10, enabled: 1 }
|
||||
|
||||
export default function ServicesPage() {
|
||||
const [services, setServices] = useState<Service[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [opLoading, setOpLoading] = useState<number | null>(null)
|
||||
const [logs, setLogs] = useState<{ serviceId: number; output: string } | null>(null)
|
||||
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
|
||||
const [editing, setEditing] = useState<Record<string, unknown> | null>(null)
|
||||
const [isNew, setIsNew] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => { fetchServices() }, [])
|
||||
|
||||
async function fetchServices() {
|
||||
const res = await fetch('/api/services')
|
||||
const data = await res.json()
|
||||
setServices(data)
|
||||
setServices(await res.json())
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const showToast = (type: 'ok' | 'err', msg: string) => {
|
||||
setToast({ type, msg }); setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
const hasDocker = (s: Service) => s.checks?.some(c => c.type === 'docker' && c.containerName)
|
||||
|
||||
async function containerAction(s: Service, action: 'restart' | 'stop' | 'start' | 'logs') {
|
||||
const confirmMsg = { restart: `确定重启 ${s.name}?`, stop: `确定停止 ${s.name}?`, start: `确定启动 ${s.name}?`, logs: '' }
|
||||
if (action !== 'logs' && !confirm(confirmMsg[action])) return
|
||||
setOpLoading(s.id)
|
||||
try {
|
||||
const res = await fetch(`/api/services/${s.id}/ops`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, lines: 100 }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
if (action === 'logs') setLogs({ serviceId: s.id, output: data.output || '(空)' })
|
||||
else { showToast('ok', `${s.name} 操作成功`); fetchServices() }
|
||||
} else showToast('err', data.error || '操作失败')
|
||||
} catch { showToast('err', '网络错误') }
|
||||
setOpLoading(null)
|
||||
}
|
||||
|
||||
const update = (f: string, v: unknown) => setEditing(prev => prev ? { ...prev, [f]: v } : null)
|
||||
|
||||
const addCheck = () => {
|
||||
const checks = (editing?.checks as Check[]) || []
|
||||
setEditing(prev => prev ? { ...prev, checks: [...checks, { type: 'http', url: '' }] } : null)
|
||||
}
|
||||
const updateCheck = (i: number, f: string, v: string) => {
|
||||
const checks = [...((editing?.checks as Check[]) || [])]
|
||||
if (checks[i]) (checks[i] as unknown as Record<string, string>)[f] = v
|
||||
update('checks', checks)
|
||||
}
|
||||
const removeCheck = (i: number) => {
|
||||
const checks = [...((editing?.checks as Check[]) || [])]
|
||||
if (checks.length <= 1) return // 至少保留一条检查方式
|
||||
checks.splice(i, 1)
|
||||
update('checks', checks)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editing?.name) { showToast('err', '请输入服务名称'); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
const url = isNew ? '/api/services' : `/api/services/${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) { showToast('ok', isNew ? '服务已添加' : '服务已更新'); setEditing(null); fetchServices() }
|
||||
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
|
||||
} catch { showToast('err', '网络错误') }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm('确定删除此服务?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/services/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) { showToast('ok', '服务已删除'); fetchServices() }
|
||||
else { const d = await res.json(); showToast('err', d.error || '删除失败') }
|
||||
} catch { showToast('err', '网络错误') }
|
||||
}
|
||||
|
||||
const startNew = () => { setEditing({ ...emptyService }); setIsNew(true) }
|
||||
const startEdit = (s: Service) => { setEditing({ ...s }); setIsNew(false) }
|
||||
|
||||
if (loading) return <div className="p-12 text-center text-slate-400">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{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 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 后续实现)')}>
|
||||
<button onClick={startNew} 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">
|
||||
<Plus size={16} /> 添加服务
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -38,7 +119,8 @@ export default function ServicesPage() {
|
|||
<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>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-500">容器操作</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-500">编辑</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -51,17 +133,40 @@ export default function ServicesPage() {
|
|||
{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 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 className="px-4 py-3 text-center">
|
||||
{hasDocker(s) ? (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button onClick={() => containerAction(s, 'restart')} disabled={opLoading === s.id}
|
||||
className="p-1.5 hover:bg-indigo-50 dark:hover:bg-indigo-500/10 rounded text-indigo-500 disabled:opacity-40" title="重启">
|
||||
<RotateCw size={15} className={opLoading === s.id ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button onClick={() => containerAction(s, 'stop')} disabled={opLoading === s.id}
|
||||
className="p-1.5 hover:bg-red-50 dark:hover:bg-red-500/10 rounded text-red-500 disabled:opacity-40" title="停止">
|
||||
<Square size={15} />
|
||||
</button>
|
||||
<button onClick={() => containerAction(s, 'logs')} disabled={opLoading === s.id}
|
||||
className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-500 disabled:opacity-40" title="查看日志">
|
||||
<ScrollText size={15} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button onClick={() => startEdit(s)} className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-400" title="编辑">
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(s.id)} className="p-1.5 hover:bg-red-50 dark:hover:bg-red-500/10 rounded text-red-400 ml-1" title="删除">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -69,6 +174,97 @@ export default function ServicesPage() {
|
|||
</table>
|
||||
{services.length === 0 && <p className="p-12 text-center text-slate-400">暂无服务,请添加被监控服务</p>}
|
||||
</div>
|
||||
|
||||
{/* 日志查看 Dialog */}
|
||||
{logs && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setLogs(null)}>
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-6 w-full max-w-3xl shadow-xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold">容器日志 — {services.find(s => s.id === logs.serviceId)?.name}</h2>
|
||||
<button onClick={() => setLogs(null)} className="text-slate-400 hover:text-slate-600 text-lg">×</button>
|
||||
</div>
|
||||
<pre className="text-xs font-mono text-slate-300 bg-slate-950 rounded-lg p-4 overflow-x-auto whitespace-pre-wrap max-h-96">{logs.output}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑服务 Dialog */}
|
||||
{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" onClick={e => e.stopPropagation()}>
|
||||
<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={String(editing.name || '')} onChange={e => update('name', e.target.value)}
|
||||
placeholder="如:Authelia SSO" 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 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>
|
||||
<select value={String(editing.category || 'endpoint')} onChange={e => update('category', 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">
|
||||
<option value="endpoint">端点</option><option value="container">容器</option><option value="custom">自定义</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">告警级别</label>
|
||||
<select value={String(editing.alert_level || 'warning')} onChange={e => update('alert_level', 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">
|
||||
<option value="critical">Critical</option><option value="warning">Warning</option><option value="info">Info</option>
|
||||
</select>
|
||||
</div>
|
||||
</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={Number(editing.check_interval) || 30} onChange={e => update('check_interval', 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">超时(秒)</label>
|
||||
<input type="number" value={Number(editing.check_timeout) || 10} onChange={e => update('check_timeout', 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" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-slate-700 dark:text-slate-300">检查方式</label>
|
||||
<button onClick={addCheck} className="text-xs text-indigo-600 hover:text-indigo-700">+ 添加检查</button>
|
||||
</div>
|
||||
{((editing.checks as Check[]) || []).map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2 mb-2">
|
||||
<select value={c.type} onChange={e => updateCheck(i, 'type', e.target.value)}
|
||||
className="px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs w-20">
|
||||
<option value="http">HTTP</option><option value="docker">Docker</option>
|
||||
</select>
|
||||
{c.type === 'http' ? (
|
||||
<input value={c.url || ''} onChange={e => updateCheck(i, 'url', e.target.value)} placeholder="https://example.com/api/health"
|
||||
className="flex-1 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs" />
|
||||
) : (
|
||||
<input value={c.containerName || ''} onChange={e => updateCheck(i, 'containerName', e.target.value)} placeholder="容器名,如 authelia"
|
||||
className="flex-1 px-2 py-1.5 rounded border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs" />
|
||||
)}
|
||||
{((editing.checks as Check[]) || []).length > 1 && (
|
||||
<button onClick={() => removeCheck(i)} className="text-red-400 hover:text-red-600 p-1"><X size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button onClick={() => setEditing(null)} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">取消</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">
|
||||
<Save size={15} /> {saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ export default function SettingsPage() {
|
|||
{ key: 'level_warning', label: 'Warning' },
|
||||
{ key: 'level_info', label: 'Info' },
|
||||
].map(l => (
|
||||
<label key={l.key} className="flex items-center gap-2 text-sm">
|
||||
<label key={l.key} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<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}
|
||||
|
|
@ -182,7 +182,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300 cursor-pointer">
|
||||
<input type="checkbox" checked={!!editing.quiet_start} onChange={e => {
|
||||
if (e.target.checked) {
|
||||
updateField('quiet_start', '23:00')
|
||||
|
|
@ -210,7 +210,7 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300 cursor-pointer">
|
||||
<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" />
|
||||
启用渠道
|
||||
|
|
|
|||
|
|
@ -0,0 +1,359 @@
|
|||
/*
|
||||
* TLYQ 统一设计系统 — 基于 Tailwind Slate 色板
|
||||
* 适用于 OA/assets/issue 及后续新增站点
|
||||
*
|
||||
* 使用方式:
|
||||
* - CSS 变量:import './reference/tlyq-design-system.css'
|
||||
* - Tailwind:颜色类已映射(见下方说明)
|
||||
*/
|
||||
|
||||
/* ==================== 设计令牌(CSS 变量) ==================== */
|
||||
:root {
|
||||
/* 背景色 */
|
||||
--bg: #f8fafc; /* slate-50 */
|
||||
--bg-subtle: #f1f5f9; /* slate-100 */
|
||||
--surface: #ffffff; /* white */
|
||||
--surface-raised: #ffffff; /* white */
|
||||
--surface-overlay: rgba(255, 255, 255, 0.95);
|
||||
|
||||
/* 文本色 */
|
||||
--fg: #0f172a; /* slate-900 */
|
||||
--fg-subtle: #334155; /* slate-700 */
|
||||
--muted: #475569; /* slate-600 */
|
||||
--muted-subtle: #94a3b8; /* slate-400 */
|
||||
|
||||
/* 边框 */
|
||||
--border: #e2e8f0; /* slate-200 */
|
||||
--border-subtle: #f1f5f9; /* slate-100 */
|
||||
|
||||
/* 主色调 — 蓝色系 */
|
||||
--accent: #2563eb; /* blue-600 */
|
||||
--accent-hover: #1d4ed8; /* blue-700 */
|
||||
--accent-active: #1e40af; /* blue-800 */
|
||||
--accent-subtle: #eff6ff; /* blue-50 */
|
||||
--accent-text: #ffffff;
|
||||
|
||||
/* 状态色 */
|
||||
--success: #059669; /* emerald-600 */
|
||||
--success-subtle: #ecfdf5; /* emerald-50 */
|
||||
--warning: #d97706; /* amber-600 */
|
||||
--warning-subtle: #fffbeb; /* amber-50 */
|
||||
--danger: #dc2626; /* red-600 */
|
||||
--danger-subtle: #fef2f2; /* red-50 */
|
||||
--info: #2563eb; /* blue-600 */
|
||||
--info-subtle: #eff6ff; /* blue-50 */
|
||||
|
||||
/* 字体 */
|
||||
--font-display: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;
|
||||
--font-body: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
||||
--font-sans: var(--font-body);
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "IBM Plex Mono", SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
|
||||
/* 字体大小 */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
|
||||
/* 行高 */
|
||||
--leading-tight: 1.25;
|
||||
--leading-snug: 1.375;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.625;
|
||||
|
||||
/* 字间距 */
|
||||
--tracking-tighter: -0.05em;
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-normal: 0;
|
||||
--tracking-wide: 0.02em;
|
||||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
|
||||
/* 间距 */
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-2xl: 24px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* 过渡 */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-spring: 500ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
/* 层级系统 */
|
||||
--z-base: 0;
|
||||
--z-raised: 10;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 300;
|
||||
--z-modal: 400;
|
||||
--z-toast: 700;
|
||||
|
||||
/* 布局 */
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-width-collapsed: 64px;
|
||||
--header-height: 56px;
|
||||
--content-max-width: 1440px;
|
||||
--content-padding: var(--space-6);
|
||||
}
|
||||
|
||||
/* ==================== 深色模式 ==================== */
|
||||
:root.dark {
|
||||
/* 背景色 */
|
||||
--bg: #020617; /* slate-950 */
|
||||
--bg-subtle: #0f172a; /* slate-900 */
|
||||
--surface: #0f172a; /* slate-900 */
|
||||
--surface-raised: #1e293b; /* slate-800 */
|
||||
--surface-overlay: rgba(15, 23, 42, 0.95);
|
||||
|
||||
/* 文本色 */
|
||||
--fg: #f8fafc; /* slate-50 */
|
||||
--fg-subtle: #cbd5e1; /* slate-300 */
|
||||
--muted: #94a3b8; /* slate-400 */
|
||||
--muted-subtle: #64748b; /* slate-500 */
|
||||
|
||||
/* 边框 */
|
||||
--border: #1e293b; /* slate-800 */
|
||||
--border-subtle: #0f172a; /* slate-900 */
|
||||
|
||||
/* 主色调 */
|
||||
--accent: #3b82f6; /* blue-500 */
|
||||
--accent-hover: #60a5fa; /* blue-400 */
|
||||
--accent-active: #93c5fd; /* blue-300 */
|
||||
--accent-subtle: rgba(59, 130, 246, 0.1);
|
||||
--accent-text: #0f172a;
|
||||
|
||||
/* 状态色 */
|
||||
--success: #34d399; /* emerald-400 */
|
||||
--success-subtle: rgba(52, 211, 153, 0.1);
|
||||
--warning: #fbbf24; /* amber-400 */
|
||||
--warning-subtle: rgba(251, 191, 36, 0.1);
|
||||
--danger: #f87171; /* red-400 */
|
||||
--danger-subtle: rgba(248, 113, 113, 0.1);
|
||||
--info: #60a5fa; /* blue-400 */
|
||||
--info-subtle: rgba(96, 165, 250, 0.1);
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* ==================== Tailwind 暗色模式映射 ==================== */
|
||||
/*
|
||||
* 以下 Tailwind 类名可直接使用:
|
||||
* 页面背景: bg-slate-50 dark:bg-slate-950
|
||||
* 卡片背景: bg-white dark:bg-slate-900
|
||||
* 边框: border-slate-200 dark:border-slate-800
|
||||
* 正文: text-slate-900 dark:text-slate-50
|
||||
* 次要文本: text-slate-700 dark:text-slate-300
|
||||
* 弱文本: text-slate-500 dark:text-slate-400
|
||||
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white
|
||||
* 成功: text-emerald-600 dark:text-emerald-400
|
||||
* 警告: text-amber-600 dark:text-amber-400
|
||||
* 危险: text-red-600 dark:text-red-400
|
||||
*/
|
||||
|
||||
/* ==================== 全局重置 ==================== */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--fg);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
/* ==================== 组件规范 ==================== */
|
||||
|
||||
/* 按钮规范
|
||||
* 主按钮: bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-sm
|
||||
* 次按钮: bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 rounded-lg
|
||||
* 幽灵按钮: hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg
|
||||
* 尺寸: sm(px-3 py-1.5 text-xs) / md(px-4 py-2 text-sm) / lg(px-6 py-2.5 text-base)
|
||||
*/
|
||||
|
||||
/* 输入框规范
|
||||
* 边框: border border-slate-300 dark:border-slate-600
|
||||
* 聚焦: focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
* 圆角: rounded-lg
|
||||
* 尺寸: h-10(px-3 text-sm)
|
||||
*/
|
||||
|
||||
/* 卡片规范
|
||||
* 基础: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6 shadow-sm
|
||||
* 悬停: hover:border-blue-200 dark:hover:border-blue-800
|
||||
*/
|
||||
|
||||
/* 表格规范
|
||||
* 头部: bg-slate-50 dark:bg-slate-800 text-slate-500 text-xs font-medium uppercase
|
||||
* 行: border-b border-slate-200 dark:border-slate-700
|
||||
* 悬停: hover:bg-slate-50 dark:hover:bg-slate-800/50
|
||||
*/
|
||||
|
||||
/* 徽标规范
|
||||
* 默认: bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300
|
||||
* 成功: bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400
|
||||
* 警告: bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400
|
||||
* 危险: bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400
|
||||
* 信息: bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||
* 圆角: rounded-full
|
||||
* 尺寸: px-2.5 py-0.5 text-xs font-medium
|
||||
*/
|
||||
|
||||
/* 弹窗规范
|
||||
* 背景: bg-white dark:bg-slate-900
|
||||
* 边框: border border-slate-200 dark:border-slate-800
|
||||
* 圆角: rounded-xl
|
||||
* 阴影: shadow-xl
|
||||
* 标题: text-lg font-semibold
|
||||
*/
|
||||
|
||||
/* 侧边栏规范
|
||||
* 宽度: w-60 (240px)
|
||||
* 定位: fixed left-0 top-0 bottom-0
|
||||
* 背景: bg-white dark:bg-slate-900
|
||||
* 边框: border-r border-slate-200 dark:border-slate-800
|
||||
* 品牌区: h-14 flex items-center
|
||||
* 导航项: px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
* 激活态: bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400
|
||||
*/
|
||||
|
||||
/* 顶栏规范
|
||||
* 高度: h-14 (56px)
|
||||
* 定位: fixed top-0
|
||||
* 背景: bg-white dark:bg-slate-900
|
||||
* 边框: border-b border-slate-200 dark:border-slate-800
|
||||
* z-index: z-30
|
||||
*/
|
||||
|
||||
/* 主内容区规范
|
||||
* 边距: ml-60 pt-14 p-6 (侧边栏+顶栏)
|
||||
* 门户布局: max-w-5xl mx-auto px-6 py-8
|
||||
*/
|
||||
|
||||
/* ==================== 统计卡片 ==================== */
|
||||
/*
|
||||
* 用于仪表盘/概览页的统计数据展示
|
||||
* 容器: bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6
|
||||
* 悬停: hover:border-blue-200 dark:hover:border-blue-800 hover:shadow-md transition-all
|
||||
* 图标区: w-10 h-10 rounded-lg bg-blue-50 dark:bg-blue-500/10 flex items-center justify-center
|
||||
* 数值: text-2xl font-semibold text-slate-900 dark:text-white font-display
|
||||
* 标签: text-sm text-slate-500 dark:text-slate-400
|
||||
* 变化趋势: text-xs font-medium (positive=text-emerald-600, negative=text-red-600)
|
||||
*/
|
||||
|
||||
/* ==================== 骨架屏加载 ==================== */
|
||||
/*
|
||||
* 用于数据加载时的占位效果
|
||||
* <div className="animate-pulse bg-slate-200 dark:bg-slate-700 rounded-lg [height]" />
|
||||
* 文本行: h-4 w-full / w-3/4
|
||||
* 标题: h-6 w-1/3
|
||||
* 卡片: h-32 w-full rounded-xl
|
||||
*/
|
||||
|
||||
/* 无缝统计条 ==================== */
|
||||
/*
|
||||
* 仪表盘顶部关键指标展示,分段之间仅 1px 间隙
|
||||
* 容器: flex gap-px bg-slate-200 dark:bg-slate-800 rounded-xl overflow-hidden
|
||||
* 段: flex-1 bg-white dark:bg-slate-900 px-6 py-4 flex flex-col gap-1
|
||||
* 标签: text-xs font-medium text-slate-500 uppercase tracking-widest
|
||||
* 数值: text-2xl font-semibold font-[family-name:var(--font-display)]
|
||||
*/
|
||||
|
||||
/* 服务状态卡片 ==================== */
|
||||
/*
|
||||
* 仪表盘中展示被监控服务实时状态
|
||||
* 正常: bg-white dark:bg-slate-900 border rounded-xl p-5
|
||||
* 悬停: hover:shadow-sm hover:-translate-y-px transition-all cursor-pointer
|
||||
* 异常: bg-red-50 dark:bg-red-500/10 border-red-600 dark:border-red-400
|
||||
* 红底红框,突出显示
|
||||
* 状态圆点: w-2.5 h-2.5 rounded-full
|
||||
* ok: bg-emerald-600 dark:bg-emerald-400 shadow-[0_0_8px_var(--success)]
|
||||
* err: bg-red-600 dark:bg-red-400 shadow-[0_0_12px_var(--danger)] animate-pulse
|
||||
* 持续时长: text-lg font-semibold font-mono text-red-600 dark:text-red-400
|
||||
* 展开区: border-t mt-3 pt-3
|
||||
*/
|
||||
|
||||
/* ==================== 图表容器 ==================== */
|
||||
/*
|
||||
* 用于包裹图表组件
|
||||
* <div 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-6">
|
||||
* <h3 className="text-lg font-semibold text-slate-900 dark:text-white">标题</h3>
|
||||
* <div className="flex items-center gap-4 text-sm text-slate-500">图例</div>
|
||||
* </div>
|
||||
* [图表内容]
|
||||
* </div>
|
||||
*/
|
||||
|
||||
/* ==================== 下拉菜单 ==================== */
|
||||
/*
|
||||
* 用于用户菜单、操作菜单
|
||||
* 容器: absolute right-0 top-full mt-1
|
||||
* bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700
|
||||
* rounded-lg shadow-lg py-1 min-w-[200px] z-[--z-dropdown]
|
||||
* 菜单项: px-4 py-2.5 text-sm flex items-center gap-3
|
||||
* hover:bg-slate-50 dark:hover:bg-slate-700
|
||||
* text-slate-700 dark:text-slate-300
|
||||
* 危险项: text-red-600 dark:text-red-400
|
||||
* 分隔线: border-t border-slate-200 dark:border-slate-700 my-1
|
||||
*/
|
||||
|
||||
/* ==================== 站点标识色 ==================== */
|
||||
/*
|
||||
* OA: blue-600 (#2563eb)
|
||||
* assets: blue-600 (#2563eb)
|
||||
* issue: blue-600 (#2563eb)
|
||||
* monitor: indigo-600 (#4f46e5)
|
||||
*
|
||||
* 站点卡片标识色(门户首页):
|
||||
* 资产管理: blue-600 (#2563eb) tag: CMDB
|
||||
* 工单跟踪: violet-600 (#7c3aed) tag: ITS
|
||||
* 监控中心: indigo-600 (#4f46e5) tag: MONITOR
|
||||
* 官网: emerald-600 (#059669) tag: WWW
|
||||
* 云平台: amber-600 (#d97706) tag: CLOUD
|
||||
* Token: rose-600 (#e11d48) tag: TOKEN
|
||||
* 代码仓库: pink-600 (#db2777) tag: GIT
|
||||
*
|
||||
* 未来考虑:迁移至 OKLCh 色彩空间(感知均匀,更现代的色域表现),
|
||||
* 需评估所有现有站点的色彩迁移成本,暂记录为未来视觉升级项。
|
||||
*/
|
||||
|
|
@ -8,17 +8,14 @@ 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() {
|
||||
export default function Sidebar({ userRole = 'viewer' }: { userRole?: string }) {
|
||||
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 },
|
||||
{ label: '仪表盘', href: '/dashboard', icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -49,7 +46,7 @@ export default function Sidebar() {
|
|||
<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">
|
||||
<Link href="/dashboard" 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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
// src/lib/container-ops.ts — 容器操作(通过 SSH 执行 Docker 命令)
|
||||
import { execFileSync } from 'child_process'
|
||||
|
||||
export type ContainerAction = 'restart' | 'stop' | 'start' | 'logs'
|
||||
|
||||
export interface ContainerOpResult {
|
||||
success: boolean
|
||||
action: ContainerAction
|
||||
containerName: string
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 通过 SSH 在 txjp 服务器上执行 Docker 命令
|
||||
function sshExec(cmd: string, timeoutMs = 15000): { stdout: string; stderr: string } {
|
||||
try {
|
||||
const result = execFileSync('ssh', ['txjp', cmd], {
|
||||
encoding: 'utf-8',
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
})
|
||||
return { stdout: result.trim(), stderr: '' }
|
||||
} catch (err: unknown) {
|
||||
const e = err as { stderr?: Buffer; stdout?: Buffer; status?: number }
|
||||
return {
|
||||
stdout: e.stdout?.toString()?.trim() || '',
|
||||
stderr: e.stderr?.toString()?.trim() || String(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 执行容器操作
|
||||
export async function containerOp(
|
||||
containerName: string,
|
||||
action: ContainerAction,
|
||||
lines?: number,
|
||||
): Promise<ContainerOpResult> {
|
||||
if (!containerName) {
|
||||
return { success: false, action, containerName: '', error: '容器名不能为空' }
|
||||
}
|
||||
|
||||
let cmd: string
|
||||
switch (action) {
|
||||
case 'restart':
|
||||
cmd = `docker restart ${containerName}`
|
||||
break
|
||||
case 'stop':
|
||||
cmd = `docker stop ${containerName}`
|
||||
break
|
||||
case 'start':
|
||||
cmd = `docker start ${containerName}`
|
||||
break
|
||||
case 'logs':
|
||||
const n = Math.min(lines || 50, 200)
|
||||
cmd = `docker logs --tail ${n} ${containerName}`
|
||||
break
|
||||
default:
|
||||
return { success: false, action, containerName, error: '未知操作' }
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = sshExec(cmd)
|
||||
if (stderr && !stdout) {
|
||||
return { success: false, action, containerName, error: stderr }
|
||||
}
|
||||
return { success: true, action, containerName, output: stdout || 'OK' }
|
||||
} catch (err) {
|
||||
return { success: false, action, containerName, error: String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
// 获取容器状态
|
||||
export function getContainerStatus(containerName: string): string | null {
|
||||
try {
|
||||
const { stdout } = sshExec(`docker ps --format '{{.Status}}' -f name=^${containerName}$`)
|
||||
return stdout || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -33,12 +33,18 @@ function ensureDb() {
|
|||
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 users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, display_name TEXT, email TEXT, role TEXT DEFAULT 'viewer', password_hash TEXT, last_login_at 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, "''")}')`)
|
||||
// 每次部署更新 localadmin 密码(确保 .env 中的密码始终生效)
|
||||
const existing = execFileSync('sqlite3', ['-json', DB_PATH, "SELECT id FROM users WHERE username = 'localadmin'"], { encoding: 'utf-8', timeout: 10000 }).trim()
|
||||
if (existing) {
|
||||
execRaw(`UPDATE users SET password_hash = '${pwdHash.replace(/'/g, "''")}', updated_at = datetime('now', '+8 hours') WHERE username = 'localadmin'`)
|
||||
} else {
|
||||
execRaw(`INSERT INTO users (username, display_name, role, password_hash, is_active, created_at, updated_at) VALUES ('localadmin', '超级管理员', 'admin', '${pwdHash.replace(/'/g, "''")}', 1, datetime('now', '+8 hours'), datetime('now', '+8 hours'))`)
|
||||
}
|
||||
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) {
|
||||
|
|
@ -48,10 +54,16 @@ function ensureDb() {
|
|||
}
|
||||
}
|
||||
|
||||
// 使用 execFileSync 直接传参,不经过 shell
|
||||
export function dbExec(sql: string): void {
|
||||
// 执行 SQL(支持可选参数化)
|
||||
export function dbExec(sql: string, params?: unknown[]): void {
|
||||
ensureDb()
|
||||
if (params && params.length > 0) {
|
||||
let i = 0
|
||||
const escaped = sql.replace(/\?/g, () => escapeSql(params[i++]))
|
||||
execFileSync('sqlite3', [DB_PATH, escaped], { timeout: 10000 })
|
||||
} else {
|
||||
execFileSync('sqlite3', [DB_PATH, sql], { timeout: 10000 })
|
||||
}
|
||||
}
|
||||
|
||||
export function dbQuery<T = Record<string, unknown>>(sql: string): T[] {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
// 服务端专用:从 JWT + DB 获取角色和权限(含 child_process 依赖,不可在客户端导入)
|
||||
import { verifyJwt } from '@shared/lib/auth/jwt'
|
||||
import { authConfig } from '@/lib/auth-config'
|
||||
import { dbQueryParams } from '@/lib/db'
|
||||
import { hasPermission } from '@/lib/permissions'
|
||||
|
||||
export function getRole(payload: Record<string, unknown> | null): string {
|
||||
if (!payload?.username) return 'viewer'
|
||||
const rows = dbQueryParams<{ role: string }>(
|
||||
'SELECT role FROM users WHERE username = ? AND is_active = 1',
|
||||
[payload.username as string]
|
||||
)
|
||||
return rows[0]?.role || 'viewer'
|
||||
}
|
||||
|
||||
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
|
||||
const payload = verifyJwt(token, authConfig.jwtSecret)
|
||||
if (!payload) return false
|
||||
return hasPermission(getRole(payload), permissionKey)
|
||||
}
|
||||
|
|
@ -85,8 +85,8 @@ export class MonitorWorker {
|
|||
}
|
||||
|
||||
// 数据清理(每日,UTC+8 时区)
|
||||
const now = new Date()
|
||||
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10)
|
||||
const d = new Date()
|
||||
const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
if (today !== this.lastCleanupDate) {
|
||||
this.cleanupOldData()
|
||||
this.lastCleanupDate = today
|
||||
|
|
@ -141,20 +141,21 @@ export class MonitorWorker {
|
|||
|
||||
const results = await Promise.allSettled(
|
||||
channels.map(async (ch) => {
|
||||
const raw = ch as unknown as Record<string, unknown>
|
||||
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',
|
||||
webhookUrl: (raw.webhook_url as string) || '',
|
||||
channelType: ((raw.channel_type as string) || 'wechat') as 'wechat' | 'email',
|
||||
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,
|
||||
quietEnabled: !!raw.quiet_enabled,
|
||||
quietStart: (raw.quiet_start as string) || '23:00',
|
||||
quietEnd: (raw.quiet_end as string) || '07:00',
|
||||
quietBypassCritical: !!raw.quiet_bypass_critical,
|
||||
cooldownMinutes: Number(raw.cooldown_minutes) || 5,
|
||||
}
|
||||
const decision = await this.alertManager.evaluate(channel, svc.id, level)
|
||||
if (decision.shouldPush) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// src/lib/permissions.ts — RBAC 权限定义 + 检查函数
|
||||
// src/lib/permissions.ts — RBAC 权限定义 + 客户端安全函数(不含 DB 依赖)
|
||||
export const PERMISSIONS = [
|
||||
{ key: 'dashboard:view', name: '查看仪表盘' },
|
||||
{ key: 'services:view', name: '查看服务列表' },
|
||||
|
|
@ -13,30 +13,17 @@ export const PERMISSIONS = [
|
|||
{ 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 拥有全部权限
|
||||
if (role === 'admin') return true
|
||||
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
|
||||
}
|
||||
}
|
||||
// checkPermission 已移至 @/lib/get-role.ts(服务端专用,API routes 请从 get-role 导入)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
// src/lib/rate-limit.ts — 简易内存速率限制器
|
||||
// 注意:仅适用于单实例部署,重启后计数器重置
|
||||
|
||||
interface AttemptRecord {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
const attempts = new Map<string, AttemptRecord>()
|
||||
const MAX_ATTEMPTS = 5
|
||||
const WINDOW_MS = 15 * 60 * 1000 // 15 分钟
|
||||
|
||||
// 清理过期记录(每 5 分钟执行一次)
|
||||
let lastCleanup = Date.now()
|
||||
function cleanup() {
|
||||
const now = Date.now()
|
||||
if (now - lastCleanup < 5 * 60 * 1000) return
|
||||
lastCleanup = now
|
||||
for (const [key, record] of attempts) {
|
||||
if (record.resetAt <= now) attempts.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export function checkRateLimit(key: string): { allowed: boolean; retryAfterMs: number } {
|
||||
cleanup()
|
||||
const now = Date.now()
|
||||
const record = attempts.get(key)
|
||||
|
||||
if (!record || record.resetAt <= now) {
|
||||
// 窗口已过期或首次尝试
|
||||
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS })
|
||||
return { allowed: true, retryAfterMs: 0 }
|
||||
}
|
||||
|
||||
if (record.count >= MAX_ATTEMPTS) {
|
||||
return { allowed: false, retryAfterMs: record.resetAt - now }
|
||||
}
|
||||
|
||||
record.count++
|
||||
return { allowed: true, retryAfterMs: 0 }
|
||||
}
|
||||
|
||||
export function resetRateLimit(key: string) {
|
||||
attempts.delete(key)
|
||||
}
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
// src/middleware.ts — 使用共享 middleware 工厂
|
||||
import { createMiddleware } from '@shared/lib/auth/middleware'
|
||||
// src/middleware.ts — V2 单 cookie 模型,Edge 验签 + iss 校验
|
||||
import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
|
||||
|
||||
export const middleware = createMiddleware({
|
||||
adminPaths: ['/admin', '/services', '/settings', '/alerts'],
|
||||
const jwtSecret = process.env.JWT_SECRET || 'oa-shared-jwt-secret-tlyq-2026'
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.tlyq.ai'
|
||||
|
||||
export const middleware = createMiddlewareV2({
|
||||
jwtSecret,
|
||||
cookieDomain,
|
||||
allowedIssuers: ['*'],
|
||||
enableApiKey: false,
|
||||
publicPaths: ['/login', '/api/auth', '/api/health', '/api/internal', '/_next', '/favicon.ico'],
|
||||
})
|
||||
|
||||
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue