Compare commits

..

No commits in common. "main" and "v2026.07.01" have entirely different histories.

57 changed files with 466 additions and 2148 deletions

View File

@ -1,20 +1,23 @@
# 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
# monitor-ai 环境变量模板
NODE_ENV=production
DATABASE_PATH=/data/monitor.db
MONITOR_MODE=local
# LDAP 配置
LDAP_URL=ldap://localhost:3890
LDAP_BASE_DN=dc=tlyq,dc=ai
# OIDC 配置
AUTHELIA_URL=http://127.0.0.1:6180
# OIDC SSO
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=monitor-oidc
OIDC_CLIENT_SECRET=<见 Authelia 配置>
OIDC_REDIRECT_URI=http://127.0.0.1:6181/api/auth/callback
OIDC_CLIENT_SECRET=<由部署脚本生成>
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
# 应急管理员
LOCALADMIN_PASSWORD=admin123
# 共享 JWT与 OA/assets/issue 相同)
JWT_SECRET=<与全站一致>
COOKIE_DOMAIN=.tlyq.ai
# LLDAP
LDAP_URL=ldap://ldap-ai:3890
# localadmin 密码(首次部署时 openssl rand -hex 16 生成)
LOCALADMIN_PASSWORD=<生成>
# 自签名证书
NODE_TLS_REJECT_UNAUTHORIZED=0

1
.gitignore vendored
View File

@ -6,4 +6,3 @@ data/*.db
*.log
.DS_Store
.DS_Store
tsconfig.tsbuildinfo

View File

@ -1,23 +1,5 @@
# Changelog
## v2026.07.02
### 新增
- **用户管理页面**用户列表、角色编辑admin/editor/viewerAPI 权限守卫
- **角色权限管理页面**11 个权限的开关式管理admin/editor/viewer 三角色独立配置
- **审计日志页面**:分页、筛选(操作类型/对象类型)、真实数据展示
- **容器操作管理**:服务管理页面增加重启/停止/查看日志功能,通过 SSH 执行 Docker 命令,安全边界(不挂载 rw docker.sock所有操作记录审计日志
- **服务 CRUD 表单**:完整的添加/编辑/删除服务对话框,支持 HTTP/Docker 多维度检查配置
- **仪表盘路由**`/` 重定向到 `/dashboard`
### 优化
- **设计规范更新**`tlyq-design-system.md` 新增「光标系统」章节,所有可交互非按钮/链接元素添加 `cursor-pointer`
- **服务表单改进**:新增服务默认包含一条检查方式,至少保留一条检查不允许删除
- **用户管理**:表格显示用户名/显示名/邮箱/角色/状态,支持内联编辑角色
- **审计日志**:支持按操作类型筛选(登录/创建/更新/删除等),按对象类型筛选(认证/服务/告警渠道/用户/角色)
## v2026.07.01
### 新增

View File

@ -261,13 +261,6 @@ monitor-ai/
| GET | `/api/status-history` | 登录 | 状态变更历史(分页,可按 service_id 筛选) |
| GET | `/api/status` | 登录 | 所有服务实时状态汇总 |
### 内部 APIx-internal-key 鉴权)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/internal/users` | 返回用户列表 |
| POST | `/api/internal/users` | OA 同步用户角色 |
### 管理
| 方法 | 路径 | 权限 | 说明 |
@ -366,12 +359,11 @@ NODE_TLS_REJECT_UNAUTHORIZED=0
| 库 | 用途 | 导入路径 |
|------|------|------|
| `@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/jwt` | JWT 签名/验证零依赖Node crypto | `import { signJwt, verifyJwt } from '@shared/lib/auth/jwt'` |
| `@shared/lib/auth/oidc` | OIDC PKCE 流程 | `import { discoverOidcConfig, buildAuthorizeUrl } from '@shared/lib/auth/oidc'` |
| `@shared/lib/auth/ldap` | LDAP 认证 | `import { ldapAuth } from '@shared/lib/auth/ldap'` |
| `@shared/lib/auth/middleware` | 路由守卫工厂 | `import { createMiddleware } from '@shared/lib/auth/middleware'` |
| `@shared/lib/auth/user-sync` | OIDC 用户同步 | `import { syncOidcUser } from '@shared/lib/auth/user-sync'` |
| `@shared/lib/alert/alert-manager` | 告警决策引擎 | `import { AlertManager } from '@shared/lib/alert/alert-manager'` |
| `@shared/lib/alert/health-checker` | 健康检查引擎 | `import { HealthChecker, HttpChecker, DockerChecker } from '@shared/lib/alert/health-checker'` |
| `@shared/lib/audit/write-audit-log` | 审计日志 | `import { writeAuditLog } from '@shared/lib/audit/write-audit-log'` |

View File

@ -4,10 +4,10 @@ WORKDIR /app
# 1. 依赖安装
COPY package.json package-lock.json ./
RUN npm ci --production=false && npm ls bcryptjs 2>/dev/null || npm install bcryptjs
RUN npm ci --production=false
# 2. 复制共享库(构建前由部署脚本创建 symlink: shared -> ../shared
COPY shared/ ./shared/
# 2. 复制共享库
COPY ../shared/ ./shared/
# 3. 复制源码并构建
COPY . .
@ -17,17 +17,12 @@ 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 tsconfig-paths
# bcryptjs 未被 standalone 打包,需在 runner 阶段安装
RUN npm install bcryptjs
RUN npm install -g tsx
EXPOSE 6181
CMD ["sh", "entrypoint.sh"]

View File

@ -1,44 +0,0 @@
# monitor-ai — 告警监控中心
tlyq.ai 统一基础设施告警监控中心,监控容器/端点健康状态,通过企业微信多 Webhook 推送告警。
## 功能
- 多维度健康检查HTTP / Docker
- 多 Webhook 渠道告警推送(企业微信)
- 告警去重、冷却机制、级别过滤、免打扰时段、抖动检测
- OIDC SSO + LDAP 备选 + localadmin 认证
- RBAC 权限admin/editor/viewer
- 审计日志(所有写操作记录)
- 暗色/亮色/自动主题切换
## 技术栈
- Next.js 15 App Routeroutput: standalone
- SQLiteexecFileSync + 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 指导文档

View File

@ -1,13 +0,0 @@
# 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__

View File

@ -4,24 +4,15 @@ 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:3000"
- "6181:6181"
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

View File

@ -5,12 +5,11 @@ set -e
echo "[entrypoint] Starting monitor-ai..."
# 启动 MonitorWorker后台tsx 即时执行 TypeScript
# Worker 失败不影响 Next.js 主进程
npx tsx scripts/monitor-worker.ts &
WORKER_PID=$! || true
WORKER_PID=$!
# 启动 Next.js standalone server
HOSTNAME=0.0.0.0 node server.js &
node server.js &
NEXT_PID=$!
echo "[entrypoint] Worker PID: $WORKER_PID, Next.js PID: $NEXT_PID"
@ -24,11 +23,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 (non-fatal)" >&2
WORKER_PID=0
echo "[entrypoint] Worker process exited" >&2
exit 1
fi
if ! kill -0 $NEXT_PID 2>/dev/null; then
echo "[entrypoint] Next.js process exited" >&2

View File

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

45
package-lock.json generated
View File

@ -19,7 +19,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.3.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.0.0"
},
"devDependencies": {
@ -1768,18 +1767,6 @@
"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",
@ -2089,15 +2076,6 @@
"@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",
@ -2390,15 +2368,6 @@
"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",
@ -2441,20 +2410,6 @@
"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",

View File

@ -19,7 +19,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.3.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.0.0"
},
"devDependencies": {

View File

@ -2,8 +2,9 @@
# monitor-ai 部署脚本
#
# 用法:
# bash deploy-monitor.sh # 部署到 txjp 服务器(默认)
# bash deploy-monitor.sh # 本地模式(默认)
# bash deploy-monitor.sh --dev # 开发模式(本地构建)
# bash deploy-monitor.sh --remote --host <IP> # 远程部署到指定服务器
#
# 首次部署自动:
# 1. 生成 OIDC_CLIENT_SECRET (openssl rand -hex 32)
@ -41,9 +42,20 @@ fi
# ============================================================
# 解析参数
# ============================================================
MODE="remote"
MODE="local"
HOST=""
while [[ $# -gt 0 ]]; do
case "$1" in
--remote)
MODE="remote"
info "模式: 远程部署"
shift
;;
--host)
HOST="$2"
info "目标主机: $HOST"
shift 2
;;
--dev)
MODE="dev"
info "模式: 开发构建"
@ -57,7 +69,7 @@ while [[ $# -gt 0 ]]; do
done
# ============================================================
# 路径配置(对齐 deploy-ai.sh使用 SSH 别名 txjp
# 路径配置
# ============================================================
LOCAL_DIR="/Users/niuniu/programs/docker"
MONITOR_DIR="$LOCAL_DIR/monitor-ai"
@ -67,6 +79,13 @@ 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
# ============================================================
# 打包源码
# ============================================================
@ -78,10 +97,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='data' --exclude='docs' \
--exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \
--exclude='.git' --exclude='._*' \
shared/ monitor-ai/ docs/design/design-system/tlyq-design-system.css || {
shared/ monitor-ai/ || {
error "打包失败"
return 1
}
@ -97,10 +116,28 @@ 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
env_exists=$(ssh txjp "test -f $REMOTE_DIR/.env && echo yes || echo no" 2>/dev/null)
if [[ -n "$run" ]]; then
env_exists=$($run "test -f $REMOTE_DIR/.env && echo yes || echo no" 2>/dev/null)
else
env_exists=$(test -f "$MONITOR_DIR/.env" && echo yes || echo no)
fi
if [[ "$env_exists" == "yes" ]]; then
info "已存在 .env跳过密钥生成"
@ -114,8 +151,10 @@ first_deploy_setup() {
LOCALADMIN_PASS=$(openssl rand -hex 16)
JWT_SECRET=$(openssl rand -hex 32)
# 生成 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)
# 生成 pbkdf2 密码哈希(通过 Authelia 容器或本地 openssl
if [[ -n "$run" ]]; then
PBKDF2_HASH=$($run "docker exec authelia authelia crypto hash generate pbkdf2 --password '$LOCALADMIN_PASS' 2>/dev/null | grep 'Digest:' | awk '{print \$2}'" 2>/dev/null)
fi
# 如果 Authelia 不可用,使用 openssl 生成备用哈希
if [[ -z "$PBKDF2_HASH" ]]; then
@ -125,8 +164,8 @@ first_deploy_setup() {
fi
# 写入 .env 文件
ssh txjp "cat > $REMOTE_DIR/.env << 'ENVEOF'
DATABASE_PATH=/app/data/monitor.db
local env_content
env_content="DATABASE_PATH=/app/data/monitor.db
MONITOR_MODE=local
AUTHELIA_URL=https://sso.tlyq.ai
OIDC_CLIENT_ID=monitor-oidc
@ -137,8 +176,15 @@ COOKIE_DOMAIN=.tlyq.ai
LDAP_URL=ldap://ldap-ai:3890
LOCALADMIN_PASSWORD=$LOCALADMIN_PASS
NODE_ENV=production
NODE_TLS_REJECT_UNAUTHORIZED=0
NODE_TLS_REJECT_UNAUTHORIZED=0"
if [[ -n "$run" ]]; then
$run "cat > $REMOTE_DIR/.env << 'ENVEOF'
$env_content
ENVEOF"
else
echo "$env_content" > "$MONITOR_DIR/.env"
fi
log "密钥已生成"
info " OIDC_CLIENT_SECRET: $OIDC_SECRET"
@ -150,6 +196,7 @@ 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
@ -157,15 +204,21 @@ update_nginx() {
return 0
fi
if [[ "$target" == "local" ]]; then
return 0
fi
log "更新 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 重载失败"
scp "$nginx_conf" "$target:$REMOTE_DIR/../nginx-proxy-ai/conf.d/monitor-ai.conf" 2>/dev/null || true
ssh "$target" "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" && log "nginx 已重载" || warn "nginx 重载失败"
}
# ============================================================
# 服务器构建
# ============================================================
build_on_server() {
local target="$1"
# 1. 计算源码快照
log "检查源码是否有变化..."
local local_md5
@ -185,13 +238,13 @@ build_on_server() {
echo " 源码快照: ${local_md5}"
local prev_md5
prev_md5=$(ssh txjp "cat ${SNAPSHOT_FILE} 2>/dev/null" 2>/dev/null || echo "")
prev_md5=$(ssh "$target" "cat ${SNAPSHOT_FILE} 2>/dev/null" 2>/dev/null || echo "")
if [[ -z "$prev_md5" ]]; then
info "首次部署,执行完整构建"
elif [[ "$prev_md5" == "$local_md5" ]]; then
info "源码无变化,跳过构建,仅重启容器"
ssh txjp "cd $REMOTE_DIR && docker compose down && docker compose up -d"
ssh "$target" "cd $REMOTE_DIR && docker compose down && docker compose up -d"
log "容器已重建"
return 0
else
@ -203,14 +256,14 @@ build_on_server() {
# 3. 上传
log "上传源码包..."
scp /tmp/monitor-ai.tar.gz txjp:/tmp/monitor-ai.tar.gz || {
scp /tmp/monitor-ai.tar.gz "$target:/tmp/monitor-ai.tar.gz" || {
error "上传失败"
return 1
}
# 4. 解压源码到 /tmp然后 rsync 到目标目录
log "解压源码并准备依赖..."
ssh txjp "\
ssh "$target" "\
rm -rf /tmp/deploy_monitor && mkdir -p /tmp/deploy_monitor && \
tar xzf /tmp/monitor-ai.tar.gz -C /tmp/deploy_monitor 2>/dev/null || true && \
rsync -a --delete \
@ -221,17 +274,13 @@ 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 txjp "cd $REMOTE_DIR && docker compose up -d"
ssh "$target" "cd $REMOTE_DIR && docker compose up -d"
# 如果 host 上没有 node_modules从容器内复制一份
ssh txjp "if [ ! -d '${REMOTE_DIR}/node_modules' ]; then
ssh "$target" "if [ ! -d '${REMOTE_DIR}/node_modules' ]; then
echo ' 首次:复制容器内 node_modules 到主机...'
docker cp ${CONTAINER}:/app/node_modules ${REMOTE_DIR}/node_modules
echo ' 完成'
@ -239,37 +288,53 @@ build_on_server() {
# 6. 安装可能新增的依赖
log "安装新增依赖..."
ssh txjp "cd ${REMOTE_DIR} && npm install --prefer-offline 2>&1 | tail -5 || true"
ssh "$target" "cd ${REMOTE_DIR} && npm install --prefer-offline 2>&1 | tail -5 || true"
# 7. 清理可能被上传的本地环境变量文件
ssh txjp "rm -f $REMOTE_DIR/.env.local $REMOTE_DIR/.env.development $REMOTE_DIR/.env.production 2>/dev/null; echo '已清理本地环境文件'"
ssh "$target" "rm -f $REMOTE_DIR/.env.local $REMOTE_DIR/.env.development $REMOTE_DIR/.env.production 2>/dev/null; echo '已清理本地环境文件'"
# 8. 重建容器Dockerfile 内完成构建)
# 8. 服务器上执行 npm run build
log "服务器上执行 npm run build..."
local build_start build_end build_dur
build_start=$(date +%s)
ssh "$target" "cd ${REMOTE_DIR} && npm run build 2>&1" | \
grep -vE "^(info|warn|npm warn|audited|packages|funding|vulnerability|npm notice|New major)" | \
tail -15
local build_exit_code=${PIPESTATUS[0]}
build_end=$(date +%s)
build_dur=$((build_end - build_start))
timing "构建耗时: ${build_dur}s"
if [[ "$build_exit_code" -ne 0 ]]; then
warn "构建可能有警告,请检查上面的输出"
fi
# 9. 重建容器
log "重建容器..."
ssh txjp "cd $REMOTE_DIR && docker compose up -d --build && docker compose restart"
ssh "$target" "cd $REMOTE_DIR && docker compose up -d && docker compose restart"
# 10. 清理构建缓存
log "清理构建缓存..."
ssh txjp "docker image prune -f 2>/dev/null || true"
ssh "$target" "docker image prune -f 2>/dev/null || true"
local disk_usage
disk_usage=$(ssh txjp "df / --output=pcent | tail -1 | tr -d ' %'" 2>/dev/null || echo "50")
disk_usage=$(ssh "$target" "df / --output=pcent | tail -1 | tr -d ' %'" 2>/dev/null || echo "50")
if [ "$disk_usage" -gt 95 ]; then
warn "磁盘使用率 ${disk_usage}%,执行紧急清理"
ssh txjp "docker builder prune -f 2>/dev/null || true"
ssh "$target" "docker builder prune -f 2>/dev/null || true"
elif [ "$disk_usage" -gt 85 ]; then
info "磁盘使用率 ${disk_usage}%执行激进清理6小时"
ssh txjp "docker builder prune -f --filter 'until=6h' 2>/dev/null || true"
ssh "$target" "docker builder prune -f --filter 'until=6h' 2>/dev/null || true"
elif [ "$disk_usage" -gt 70 ]; then
info "磁盘使用率 ${disk_usage}%执行正常清理24小时"
ssh txjp "docker builder prune -f --filter 'until=24h' 2>/dev/null || true"
ssh "$target" "docker builder prune -f --filter 'until=24h' 2>/dev/null || true"
else
info "磁盘使用率 ${disk_usage}%执行保守清理48小时"
ssh txjp "docker builder prune -f --filter 'until=48h' 2>/dev/null || true"
ssh "$target" "docker builder prune -f --filter 'until=48h' 2>/dev/null || true"
fi
# 11. 保存快照
ssh txjp "echo '$local_md5' > ${SNAPSHOT_FILE}"
ssh "$target" "echo '$local_md5' > ${SNAPSHOT_FILE}"
}
# ============================================================
@ -307,19 +372,34 @@ echo ""
BUILD_START=$(date +%s)
case "$MODE" in
remote)
# 远程模式:部署到 txjp 服务器
local)
# 本地模式:部署到 txjp 服务器(默认行为)
log "部署目标: $MONITOR_DIR → txjp:$REMOTE_DIR"
echo ""
# 首次部署检查
first_deploy_setup
first_deploy_setup "$SSH_TARGET"
# 构建
build_on_server
build_on_server "$SSH_TARGET"
# 更新 nginx
update_nginx
update_nginx "$SSH_TARGET"
;;
remote)
# 远程模式:部署到指定服务器
log "部署目标: $MONITOR_DIR$HOST:$REMOTE_DIR"
echo ""
# 首次部署检查
first_deploy_setup "$HOST"
# 构建
build_on_server "$HOST"
# 更新 nginx
update_nginx "$HOST"
;;
dev)
@ -341,7 +421,7 @@ if [[ "$MODE" == "dev" ]]; then
exit 0
fi
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 "???")
STATUS=$(ssh "$SSH_TARGET" "curl -s -o /dev/null -w '%{http_code}' -k 'https://monitor.tlyq.ai/api/health' 2>/dev/null" 2>/dev/null || echo "???")
if [[ "$STATUS" == "200" ]]; then
log "部署成功!总耗时: ${TOTAL_DUR}s | 访问 https://monitor.tlyq.ai"
exit 0

View File

@ -1,121 +1,11 @@
'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
}
// src/app/admin/audit-logs/page.tsx — 审计日志(占位)
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>
<p className="text-sm text-slate-400 mt-1"></p>
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1>
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center">
<p className="text-slate-400"></p>
</div>
<div 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>
)
}

View File

@ -1,48 +0,0 @@
// 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>
)
}

View File

@ -1,130 +1,11 @@
'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 }[] }
// src/app/admin/roles/page.tsx — 角色权限管理(占位)
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">
{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>
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1>
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center">
<p className="text-slate-400"></p>
</div>
{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>
)
}

View File

@ -1,217 +1,11 @@
'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
}
// src/app/admin/users/page.tsx — 用户管理占位Phase 3 完成)
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">
{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>
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1>
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center">
<p className="text-slate-400"> UI</p>
</div>
<div 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>
)
}

View File

@ -1,64 +0,0 @@
// 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} 天前的日志` })
}

View File

@ -1,71 +0,0 @@
// 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 })
}

View File

@ -1,94 +0,0 @@
// 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 })
}

View File

@ -1,34 +0,0 @@
// 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)
}

View File

@ -3,13 +3,12 @@ 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(getRole(payload), 'dashboard:view')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'dashboard:view')) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
return NextResponse.json({ status: 'running', uptime: process.uptime() })
}

View File

@ -5,13 +5,12 @@ 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(getRole(payload), 'alerts:manage')) return null
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) return null
return payload
}

View File

@ -5,13 +5,12 @@ 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(getRole(payload), 'alerts:manage')) {
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params

View File

@ -5,13 +5,12 @@ 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(getRole(payload), 'alerts:manage')) {
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const channels = dbQuery('SELECT id, name, channel_type, webhook_url, enabled, level_critical, level_warning, level_info, quiet_enabled, quiet_start, quiet_end, quiet_bypass_critical, cooldown_minutes, created_at, updated_at FROM alert_channels ORDER BY id')
@ -22,7 +21,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(getRole(payload), 'alerts:manage')) {
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()

View File

@ -4,13 +4,12 @@ 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(getRole(payload), 'alerts:view')) {
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'alerts:view')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { searchParams } = new URL(request.url)

View File

@ -1,53 +1,97 @@
// GET /api/auth/callback — OIDC callbackV2使用 shared handleOidcCallback 工厂)
// GET /api/auth/callback — OIDC callback 处理
import { NextRequest, NextResponse } from 'next/server'
import { handleOidcCallback } from '@shared/lib/auth/handle-callback'
import { exchangeCodeForToken, getUserinfo } from '@shared/lib/auth/oidc'
import { signJwt } from '@shared/lib/auth/jwt'
import { syncOidcUser } from '@shared/lib/auth/user-sync'
import { authConfig } from '@/lib/auth-config'
import { dbQueryParams, dbExec } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
export async function GET(request: NextRequest) {
return handleOidcCallback(request, {
oidc: {
autheliaUrl: authConfig.autheliaUrl,
clientId: authConfig.oidcClientId,
clientSecret: authConfig.oidcClientSecret,
redirectUri: authConfig.oidcRedirectUri,
},
jwtSecret: authConfig.jwtSecret,
cookieDomain: authConfig.cookieDomain,
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
if (error) {
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(error)}`, request.url))
}
if (!code || !state) {
return NextResponse.redirect(new URL('/login?error=missing_params', request.url))
}
// 验证 state
const savedState = request.cookies.get('oidc_state')?.value
if (!savedState || savedState !== state) {
return NextResponse.redirect(new URL('/login?error=state_mismatch', request.url))
}
// 取出 code_verifier
const codeVerifier = request.cookies.get('oidc_code_verifier')?.value
if (!codeVerifier) {
return NextResponse.redirect(new URL('/login?error=missing_verifier', request.url))
}
// 换取 token
const tokenResult = await exchangeCodeForToken(
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
code, codeVerifier,
)
if (!tokenResult.success) {
return NextResponse.redirect(new URL(`/login?error=${encodeURIComponent(tokenResult.error || 'token_exchange_failed')}`, request.url))
}
// 验证 nonce
const savedNonce = request.cookies.get('oidc_nonce')?.value
if (savedNonce && tokenResult.nonce && savedNonce !== tokenResult.nonce) {
return NextResponse.redirect(new URL('/login?error=nonce_mismatch', request.url))
}
// 获取 userinfo
const userinfo = await getUserinfo(authConfig.autheliaUrl, tokenResult.accessToken)
// 用户同步
const user = syncOidcUser({
getUser: (username) => {
const rows = dbQueryParams<{ id: number; role: string }>(
'SELECT id, role FROM users WHERE username = ? AND is_active = 1', [username]
)
const rows = dbQuery<{ id: number; role: string }>(`SELECT id, role FROM users WHERE username = ${escapeSql(username)}`)
return rows[0] ?? null
},
createUser: (username, displayName, email) => {
dbExec(
'INSERT INTO users (username, display_name, email, role) VALUES (?, ?, ?, ?)',
[username, displayName, email, 'viewer']
)
const row = dbQueryParams<{ id: number }>('SELECT last_insert_rowid() AS id', [])
dbExec(`INSERT INTO users (username, display_name, email, role) VALUES (${escapeSql(username)}, ${escapeSql(displayName)}, ${escapeSql(email)}, 'viewer')`)
const row = dbQuery<{ id: number }>(`SELECT last_insert_rowid() AS id`)
return { id: row[0]?.id ?? 0, role: 'viewer' }
},
updateUser: (username, displayName, email) => {
dbExec(
"UPDATE users SET display_name = ?, email = ?, updated_at = datetime('now', '+8 hours') WHERE username = ?",
[displayName, email, username]
)
dbExec(`UPDATE users SET display_name = ${escapeSql(displayName)}, email = ${escapeSql(email)}, updated_at = datetime('now', '+8 hours') WHERE username = ${escapeSql(username)}`)
},
}, userinfo)
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',
})
},
// 签发 JWT
const token = signJwt({
secret: authConfig.jwtSecret,
payload: { username: userinfo.preferred_username, displayName: userinfo.name, role: user.role },
})
const response = NextResponse.redirect(new URL('/', request.url))
// 签发 tlyq_session cookie
response.cookies.set('tlyq_session', token, {
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: process.env.NODE_ENV === 'production' ? authConfig.cookieDomain : undefined, path: '/', maxAge: 604800,
})
// 清理 OIDC 临时 cookie
response.cookies.delete('oidc_code_verifier')
response.cookies.delete('oidc_state')
response.cookies.delete('oidc_nonce')
// 审计日志
writeAuditLog({ exec: dbExec }, {
userId: user.id, username: userinfo.preferred_username, action: 'login',
entityType: 'auth', details: { method: 'oidc', isNew: user.isNew },
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
})
return response
}

View File

@ -1,16 +1,25 @@
// GET /api/auth/login/oidc — OIDC SSO 重定向V2使用 shared handleOidcLogin 工厂)
import { handleOidcLogin } from '@shared/lib/auth/handle-login'
// GET /api/auth/login/oidc — OIDC SSO 重定向
import { NextResponse } from 'next/server'
import { generatePkce, generateState, buildAuthorizeUrl } from '@shared/lib/auth/oidc'
import { authConfig } from '@/lib/auth-config'
export async function GET(request: Request) {
const url = new URL(request.url)
const switchUser = url.searchParams.get('switch') === '1'
export async function GET() {
const { codeVerifier, codeChallenge } = generatePkce()
const state = generateState()
const nonce = generateState()
return handleOidcLogin({
autheliaUrl: authConfig.autheliaUrl,
clientId: authConfig.oidcClientId,
clientSecret: authConfig.oidcClientSecret,
redirectUri: authConfig.oidcRedirectUri,
switchUser,
})
const url = buildAuthorizeUrl(
{ autheliaUrl: authConfig.autheliaUrl, clientId: authConfig.oidcClientId, clientSecret: authConfig.oidcClientSecret, redirectUri: authConfig.oidcRedirectUri },
{ codeChallenge, state, nonce },
)
const response = NextResponse.redirect(url)
// 存储 PKCE 参数到 httpOnly cookie5 分钟过期)
const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' as const, maxAge: 300, path: '/' }
response.cookies.set('oidc_code_verifier', codeVerifier, cookieOpts)
response.cookies.set('oidc_state', state, cookieOpts)
response.cookies.set('oidc_nonce', nonce, cookieOpts)
return response
}

View File

@ -1,12 +1,11 @@
// POST /api/auth/login — LDAP 本地登录(回退通道)
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { signJwtV2 } from '@shared/lib/auth/jwt-v2'
import { signJwt } from '@shared/lib/auth/jwt'
import { ldapAuth } from '@shared/lib/auth/ldap'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbQueryParams } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
import { checkRateLimit, resetRateLimit } from '@/lib/rate-limit'
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
import { writeAuditLog } from '@shared/lib/audit/write-audit-log'
export async function POST(request: NextRequest) {
let username: string, password: string
@ -23,47 +22,35 @@ 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; display_name: string | null }>(`SELECT password_hash, role, display_name FROM users WHERE username = 'localadmin'`)
const users = dbQuery<{ password_hash: string; role: string }>(`SELECT password_hash, role FROM users WHERE username = 'localadmin'`)
if (users.length === 0) {
return NextResponse.json({ error: 'localadmin 未配置' }, { status: 401 })
}
const localadminUser = users[0]
// 与数据库存储的 bcrypt 哈希比较(异步避免阻塞事件循环)
if (!await bcrypt.compare(password, localadminUser.password_hash)) {
writeAuditLog({
// 与数据库存储的 bcrypt 哈希比较
if (!bcrypt.compareSync(password, users[0].password_hash)) {
writeAuditLog({ exec: dbExec }, {
username, action: 'login_failed', entityType: 'auth',
details: { method: 'localadmin', reason: 'wrong_password' },
ipAddress: ip,
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
})
return NextResponse.json({ error: '密码错误' }, { status: 401 })
}
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 } })
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username: 'localadmin', role: 'admin', displayName: 'localadmin' } })
writeAuditLog({
writeAuditLog({ exec: dbExec }, {
username, action: 'login', entityType: 'auth',
details: { method: 'localadmin' },
ipAddress: ip,
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
})
const response = NextResponse.json({
user: { username: 'localadmin', role, displayName },
user: { username: 'localadmin', role: 'admin', displayName: 'localadmin' },
})
response.cookies.set('tlyq_session', token, {
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 604800,
httpOnly: true, secure: false, sameSite: 'lax', path: '/', maxAge: 604800,
})
return response
}
@ -75,32 +62,30 @@ export async function POST(request: NextRequest) {
)
if (!result.success) {
writeAuditLog({
writeAuditLog({ exec: dbExec }, {
username, action: 'login_failed', entityType: 'auth',
details: { method: 'ldap', reason: result.error },
ipAddress: ip,
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
})
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 })
}
resetRateLimit(rateLimitKey)
// 签发 JWT
const users = dbQueryParams<{ role: string }>(`SELECT role FROM users WHERE username = ?`, [username])
const users = dbQuery(`SELECT role FROM users WHERE username = ${escapeSql(username)}`)
const role = users.length > 0 ? users[0].role as string : 'viewer'
writeAuditLog({
writeAuditLog({ exec: dbExec }, {
username, action: 'login', entityType: 'auth',
details: { method: 'ldap', role },
ipAddress: ip,
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
})
const token = signJwtV2({ secret: authConfig.jwtSecret, iss: 'monitor.tlyq.ai', payload: { username, displayName: result.displayName || username } })
const token = signJwt({ secret: authConfig.jwtSecret, payload: { username, role, displayName: result.displayName || username } })
const response = NextResponse.json({
user: { username, role, displayName: result.displayName || username },
})
response.cookies.set('tlyq_session', token, {
httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 604800,
httpOnly: true, secure: false, sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 604800,
})
return response
}

View File

@ -1,30 +1,11 @@
// GET + POST /api/auth/logout — 退出登录(清除 cookie + 302 跳转 /login
// POST /api/auth/logout — 退出登录
import { NextResponse } from 'next/server'
import { authConfig } from '@/lib/auth-config'
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,
})
export async function POST() {
// 清除 tlyq_session cookie
const logoutUrl = `${authConfig.autheliaUrl}/api/oidc/end_session`
const response = NextResponse.redirect(logoutUrl)
response.cookies.set('tlyq_session', '', { httpOnly: true, secure: false, sameSite: 'lax', domain: authConfig.cookieDomain, path: '/', maxAge: 0 })
return response
}
export async function GET() { return logoutResponse() }
export async function POST() { return logoutResponse() }

View File

@ -1,8 +1,7 @@
// GET /api/auth/me — 当前用户信息V2JWT 不含 role从本地 DB 查询)
// GET /api/auth/me — 当前用户信息
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwtV2, extractIss } from '@shared/lib/auth/jwt-v2'
import { verifyJwt } from '@shared/lib/auth/jwt'
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
@ -10,24 +9,16 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
// Layer 3 defense-in-depth提取 iss → 自引用验签
const iss = extractIss(token) || '*'
const payload = verifyJwtV2(token, authConfig.jwtSecret, iss)
const payload = verifyJwt(token, authConfig.jwtSecret)
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, display_name: displayName, role },
user: {
username: payload.username,
display_name: payload.displayName || payload.username,
role: payload.role || 'viewer',
},
})
}

View File

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

View File

@ -1,17 +0,0 @@
// 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 })
}

View File

@ -1,51 +0,0 @@
// 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 })
}
}

View File

@ -5,14 +5,13 @@ 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(getRole(payload), 'services:manage')) {
if (!hasPermission(String(payload.role || 'viewer'), 'services:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

View File

@ -1,62 +0,0 @@
// 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)
}

View File

@ -5,14 +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) return null
if (!hasPermission(getRole(payload), 'services:manage')) return null
if (!hasPermission(String(payload.role || 'viewer'), 'services:manage')) return null
return payload
}

View File

@ -6,17 +6,8 @@ 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 || '[]')) }))
@ -28,7 +19,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(getRole(payload), 'services:manage')) {
if (!hasPermission(String(payload.role || 'viewer'), 'services:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

View File

@ -1,20 +1,8 @@
// 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

View File

@ -1,23 +1,10 @@
// GET /api/status — 所有服务实时状态摘要
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
import { NextResponse } from 'next/server'
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), 'services:view')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
export async function GET() {
const services = dbQuery('SELECT id, name, category, alert_level, current_status, status_since FROM services WHERE enabled = 1 ORDER BY display_order, id')
const counts = { normal: 0, abnormal: 0, unknown: 0 }
services.forEach((s: Record<string, unknown>) => { const st = String(s.current_status); if (st === 'normal') counts.normal++; else if (st === 'abnormal') counts.abnormal++; else counts.unknown++ })
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 })
return NextResponse.json({ services, counts, timestamp: new Date().toISOString() })
}

View File

@ -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 userRole={user?.role} />
<Sidebar />
<TopBar user={user} />
<main className="ml-60 pt-14 min-h-screen">
<div className="p-6">{children}</div>

View File

@ -1,93 +0,0 @@
'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>
)
}

View File

@ -3,7 +3,7 @@
@source "../../shared";
@source "../../src";
/* TLYQ 设计系统 CSS 变量 */
@import './tlyq-design-system.css' layer(base);
@import '../../../docs/design/design-system/tlyq-design-system.css' layer(base);
@layer base {
body {

View File

@ -2,5 +2,5 @@
import LoginPage from '@shared/ui/login-page'
export default function Page() {
return <LoginPage siteName="告警监控中心" redirectPath="/dashboard" />
return <LoginPage siteName="告警监控中心" />
}

View File

@ -1,5 +1,93 @@
import { redirect } from 'next/navigation'
'use client'
// src/app/page.tsx — 仪表盘首页(四层结构)
import { useState, useEffect } from 'react'
export default function HomePage() {
redirect('/dashboard')
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>
)
}

View File

@ -1,111 +1,30 @@
'use client'
// src/app/services/page.tsx — 服务管理页面CRUD + 容器操作)
// src/app/services/page.tsx — 服务管理页面
import { useState, useEffect } from 'react'
import { Plus, RotateCw, Square, ScrollText, X, Save, Trash2, Edit3 } from 'lucide-react'
import { Server, Plus, Edit, Trash2, Eye } from 'lucide-react'
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 }
interface Service { id: number; name: string; category: string; alert_level: string; checks: Array<{type: string}>; check_interval: number; enabled: number; current_status: string }
export default function ServicesPage() {
const [services, setServices] = useState<Service[]>([])
const [loading, setLoading] = useState(true)
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')
setServices(await res.json())
const data = await res.json()
setServices(data)
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 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">
<button className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg px-4 py-2 text-sm font-medium" onClick={() => alert('添加服务(完整 UI 待 Phase 4 后续实现)')}>
<Plus size={16} />
</button>
</div>
@ -119,8 +38,7 @@ 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-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>
<th className="text-right px-4 py-3 font-medium text-slate-500"></th>
</tr>
</thead>
<tbody>
@ -133,40 +51,17 @@ 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-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 className="px-4 py-3 text-right">
<button className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-400" title="查看"><Eye size={16} /></button>
<button className="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded text-slate-400" title="编辑"><Edit size={16} /></button>
<button className="p-1.5 hover:bg-red-50 dark:hover:bg-red-500/10 rounded text-red-400" title="删除"><Trash2 size={16} /></button>
</td>
</tr>
))}
@ -174,97 +69,6 @@ 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">&times;</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>
)
}

View File

@ -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 cursor-pointer">
<label key={l.key} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={!!(editing as Record<string, unknown>)[l.key]} onChange={e => updateField(l.key, e.target.checked ? 1 : 0)}
className="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
{l.label}
@ -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 cursor-pointer">
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={!!editing.quiet_start} onChange={e => {
if (e.target.checked) {
updateField('quiet_start', '23:00')
@ -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 cursor-pointer">
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={editing.enabled === 1} onChange={e => updateField('enabled', e.target.checked ? 1 : 0)}
className="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />

View File

@ -1,359 +0,0 @@
/*
* 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 色彩空间感知均匀更现代的色域表现
* 需评估所有现有站点的色彩迁移成本暂记录为未来视觉升级项
*/

View File

@ -8,14 +8,17 @@ 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({ userRole = 'viewer' }: { userRole?: string }) {
export default function Sidebar() {
const pathname = usePathname()
const isActive = (href: string) => pathname === href || (href !== '/' && pathname.startsWith(href))
// 默认显示所有导航项(客户端权限在 API 层强制验证,此处仅 UI 过滤)
const userRole = 'admin' // TODO: 从 cookie 或 session 读取实际角色
const sections: NavSection[] = [
{
items: [
{ label: '仪表盘', href: '/dashboard', icon: LayoutDashboard },
{ label: '仪表盘', href: '/', icon: LayoutDashboard },
],
},
{
@ -46,7 +49,7 @@ export default function Sidebar({ userRole = 'viewer' }: { userRole?: string })
<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="/dashboard" className="flex items-center gap-3">
<Link href="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center text-white font-bold text-sm">M</div>
<span className="font-semibold text-slate-900 dark:text-white">monitor-ai</span>
</Link>

View File

@ -1,80 +0,0 @@
// 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
}
}

View File

@ -33,18 +33,12 @@ 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, 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 users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, display_name TEXT, email TEXT, role TEXT DEFAULT 'viewer', password_hash TEXT, is_active INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now', '+8 hours')), updated_at TEXT DEFAULT (datetime('now', '+8 hours')))`)
execRaw(`CREATE TABLE IF NOT EXISTS permissions (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE NOT NULL, name TEXT NOT NULL, description TEXT, created_at TEXT DEFAULT (datetime('now', '+8 hours')))`)
execRaw(`CREATE TABLE IF NOT EXISTS role_permissions (id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL, permission_key TEXT NOT NULL REFERENCES permissions(key), created_at TEXT DEFAULT (datetime('now', '+8 hours')), UNIQUE(role, permission_key))`)
const bcrypt = require('bcryptjs')
const pwdHash = bcrypt.hashSync(config.localadminPassword, 10)
// 每次部署更新 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'))`)
}
execRaw(`INSERT OR IGNORE INTO users (username, display_name, role, password_hash) VALUES ('localadmin', '超级管理员', 'admin', '${pwdHash.replace(/'/g, "''")}')`)
const raw = execFileSync('sqlite3', ['-json', DB_PATH, 'SELECT id FROM alert_channels'], { encoding: 'utf-8', timeout: 10000 }).trim()
const channels = raw ? JSON.parse(raw) : []
if (channels.length === 0) {
@ -54,16 +48,10 @@ function ensureDb() {
}
}
// 执行 SQL支持可选参数化
export function dbExec(sql: string, params?: unknown[]): void {
// 使用 execFileSync 直接传参,不经过 shell
export function dbExec(sql: string): 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 })
}
execFileSync('sqlite3', [DB_PATH, sql], { timeout: 10000 })
}
export function dbQuery<T = Record<string, unknown>>(sql: string): T[] {

View File

@ -1,22 +0,0 @@
// 服务端专用:从 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)
}

View File

@ -85,8 +85,8 @@ export class MonitorWorker {
}
// 数据清理每日UTC+8 时区)
const d = new Date()
const today = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
const now = new Date()
const today = new Date(now.getTime() + 8 * 3600000).toISOString().slice(0, 10)
if (today !== this.lastCleanupDate) {
this.cleanupOldData()
this.lastCleanupDate = today
@ -141,21 +141,20 @@ 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: (raw.webhook_url as string) || '',
channelType: ((raw.channel_type as string) || 'wechat') as 'wechat' | 'email',
webhookUrl: (ch as Record<string, unknown>).webhook_url as string || '',
channelType: (ch as Record<string, unknown>).channel_type as string || 'wechat',
enabled: !!ch.enabled_num,
levelCritical: !!ch.level_critical_num,
levelWarning: !!ch.level_warning_num,
levelInfo: !!ch.level_info_num,
quietEnabled: !!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,
quietEnabled: !!(ch as Record<string, unknown>).quiet_enabled,
quietStart: (ch as Record<string, unknown>).quiet_start as string || '23:00',
quietEnd: (ch as Record<string, unknown>).quiet_end as string || '07:00',
quietBypassCritical: !!(ch as Record<string, unknown>).quiet_bypass_critical,
cooldownMinutes: Number((ch as Record<string, unknown>).cooldown_minutes) || 5,
}
const decision = await this.alertManager.evaluate(channel, svc.id, level)
if (decision.shouldPush) {

View File

@ -1,4 +1,4 @@
// src/lib/permissions.ts — RBAC 权限定义 + 客户端安全函数(不含 DB 依赖)
// src/lib/permissions.ts — RBAC 权限定义 + 检查函数
export const PERMISSIONS = [
{ key: 'dashboard:view', name: '查看仪表盘' },
{ key: 'services:view', name: '查看服务列表' },
@ -13,17 +13,30 @@ 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
if (role === 'admin') return true // admin 拥有全部权限
const perms = ROLE_DEFAULT_PERMISSIONS[role]
return perms ? perms.includes(permissionKey) : false
}
// checkPermission 已移至 @/lib/get-role.ts服务端专用API routes 请从 get-role 导入)
// 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
}
}

View File

@ -1,45 +0,0 @@
// 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)
}

View File

@ -1,15 +1,8 @@
// src/middleware.ts — V2 单 cookie 模型Edge 验签 + iss 校验
import { createMiddlewareV2 } from '@shared/lib/auth/middleware-v2'
// src/middleware.ts — 使用共享 middleware 工厂
import { createMiddleware } from '@shared/lib/auth/middleware'
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 middleware = createMiddleware({
adminPaths: ['/admin', '/services', '/settings', '/alerts'],
})
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }

File diff suppressed because one or more lines are too long