feat: 审计日志 + 邮件监控 + 修复删除工单 bug
This commit is contained in:
parent
1c1555e417
commit
b7b2b34a1f
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -1,5 +1,38 @@
|
||||||
# 变更日志
|
# 变更日志
|
||||||
|
|
||||||
|
## 2026-06-24
|
||||||
|
|
||||||
|
- [修复] SQLite 时区根源修复:重建表修改所有时间列 DEFAULT 为 `datetime('now', '+8 hours')`,涉及 7 个表 9 个列
|
||||||
|
|
||||||
|
## 2026-06-23(审计日志)
|
||||||
|
|
||||||
|
- [新增] 审计日志详情可视化:字段变更用表格对比,message/keys 格式分别用文本和列表展示
|
||||||
|
- [新增] 保留天数可编辑:默认隐藏保存按钮,修改值后蓝色高亮显示,保存成功/失败有颜色反馈
|
||||||
|
- [优化] 审计日志列表详情列:显示操作摘要(邮件监控/微信推送/配置修改等),不再显示"-"
|
||||||
|
- [优化] 表格列宽统一:时间/用户/操作/对象列防止换行,与 assets-ai 风格一致
|
||||||
|
- [修复] 监控设置页"已保存"提示重复:SectionSaveButton 移除冗余消息渲染
|
||||||
|
- [修复] 导出文件名使用北京时间(修复凌晨时区偏移)
|
||||||
|
|
||||||
|
## 2026-06-23(邮件监控集成)
|
||||||
|
|
||||||
|
- [新增] 邮件监控系统:将 zabbix-01 上的 mail_monitor.py 完整迁移到 issue-ai,Node.js 实现
|
||||||
|
- [新增] Background Worker:独立进程运行(entrypoint.sh),node-cron 定时调度,与 Next.js Server 同时启动
|
||||||
|
- [新增] IMAP 邮件监控:自动连接邮箱、过滤故障邮件、解析 HTML 表格、提取故障信息
|
||||||
|
- [新增] 自动工单创建:从邮件解析结果自动创建工单,关联设备信息
|
||||||
|
- [新增] 微信 webhook 推送:故障通知、可用性档位提醒、工单结单提醒
|
||||||
|
- [新增] OEM 诊断提醒:每小时提醒、30 分钟特别提醒、超时提醒
|
||||||
|
- [新增] OEM 维修提醒:每档前 1 小时提醒、每档超时提醒、全部超出提醒
|
||||||
|
- [新增] 静默期管理:0:00-7:00 不发定时提醒,7 点自动 flush 积压消息
|
||||||
|
- [新增] 可用性计算:五档位(1%/3%/5%/10%),月度累计故障时长
|
||||||
|
- [新增] 6 张数据库表:settings、fault_records、reminder_state、processed_emails、pending_messages、monitor_logs
|
||||||
|
- [新增] 设置页面(/settings/monitor):邮箱配置、邮件过滤、微信推送、运行参数
|
||||||
|
- [新增] 分模块保存按钮 + 高亮修改字段 + 错误详情可点击展开
|
||||||
|
- [新增] 运行状态面板:今日处理、累计、错误、进行中故障均可点击展开详情
|
||||||
|
- [新增] 监控 API:status/start/stop/trigger/settings/test-mail/test-wechat(admin 权限)
|
||||||
|
- [新增] 审计日志集成:所有监控操作记录到 audit_logs
|
||||||
|
- [新增] 数据迁移脚本:从 zabbix-01 迁移历史故障数据
|
||||||
|
- [新增] Docker 集成:entrypoint.sh 包装脚本,builder 阶段编译 Worker 模块
|
||||||
|
|
||||||
## 2026-05-18
|
## 2026-05-18
|
||||||
|
|
||||||
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
|
- [新增] 用户详情页 — 点击用户名查看完整信息,支持编辑和键盘导航
|
||||||
|
|
|
||||||
34
CLAUDE.md
34
CLAUDE.md
|
|
@ -49,6 +49,16 @@ npm run import # 导入工单
|
||||||
| `src/lib/excel.ts` | Excel 解析与导出 |
|
| `src/lib/excel.ts` | Excel 解析与导出 |
|
||||||
| `src/lib/pdf.ts` | PDF 生成(puppeteer) |
|
| `src/lib/pdf.ts` | PDF 生成(puppeteer) |
|
||||||
| `src/lib/audit.ts` | 审计日志(writeAuditLog、diffObjects、getClientIP) |
|
| `src/lib/audit.ts` | 审计日志(writeAuditLog、diffObjects、getClientIP) |
|
||||||
|
| `src/lib/monitor/types.ts` | 邮件监控:类型定义 |
|
||||||
|
| `src/lib/monitor/settings-manager.ts` | 邮件监控:配置管理(AES 加密) |
|
||||||
|
| `src/lib/monitor/worker.ts` | 邮件监控:Worker 主类(cron 调度、主循环) |
|
||||||
|
| `src/lib/monitor/mail-monitor.ts` | 邮件监控:IMAP 连接、邮件过滤、解析 |
|
||||||
|
| `src/lib/monitor/ticket-processor.ts` | 邮件监控:工单创建、故障记录 |
|
||||||
|
| `src/lib/monitor/wechat-pusher.ts` | 邮件监控:微信 webhook 推送 |
|
||||||
|
| `src/lib/monitor/availability-engine.ts` | 邮件监控:可用性档位计算 |
|
||||||
|
| `src/lib/monitor/reminder-engine.ts` | 邮件监控:OEM 诊断/维修提醒 |
|
||||||
|
| `scripts/monitor-worker.ts` | 邮件监控:Worker 独立入口脚本 |
|
||||||
|
| `scripts/entrypoint.sh` | Docker 包装脚本(同时启动 Server + Worker) |
|
||||||
| `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput |
|
| `src/types/ticket.ts` | TicketCreateInput / TicketUpdateInput |
|
||||||
| `src/types/report.ts` | ReportType / ReportData |
|
| `src/types/report.ts` | ReportType / ReportData |
|
||||||
|
|
||||||
|
|
@ -67,6 +77,12 @@ npm run import # 导入工单
|
||||||
| `ticket_steps` | 工单时间线(关联 ticket_id) |
|
| `ticket_steps` | 工单时间线(关联 ticket_id) |
|
||||||
| `reports` | 报告记录 |
|
| `reports` | 报告记录 |
|
||||||
| `audit_logs` | 审计日志 |
|
| `audit_logs` | 审计日志 |
|
||||||
|
| `settings` | 键值对配置(含邮件监控配置) |
|
||||||
|
| `fault_records` | 邮件监控:故障记录 |
|
||||||
|
| `reminder_state` | 邮件监控:提醒状态 |
|
||||||
|
| `processed_emails` | 邮件监控:已处理邮件去重 |
|
||||||
|
| `pending_messages` | 邮件监控:静默期积压队列 |
|
||||||
|
| `monitor_logs` | 邮件监控:运行日志 |
|
||||||
|
|
||||||
### tickets 表核心字段
|
### tickets 表核心字段
|
||||||
|
|
||||||
|
|
@ -137,6 +153,19 @@ npm run import # 导入工单
|
||||||
| GET/POST | `/api/roles` | 角色列表/创建 |
|
| GET/POST | `/api/roles` | 角色列表/创建 |
|
||||||
| GET/PUT/DELETE | `/api/roles/[id]` | 单个角色操作 |
|
| GET/PUT/DELETE | `/api/roles/[id]` | 单个角色操作 |
|
||||||
|
|
||||||
|
### 邮件监控(admin 权限)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/monitor/status` | 运行状态 + 健康检查 + 详情 |
|
||||||
|
| POST | `/api/monitor/start` | 启用监控 |
|
||||||
|
| POST | `/api/monitor/stop` | 停用监控 |
|
||||||
|
| POST | `/api/monitor/trigger` | 手动触发检查(30 秒冷却) |
|
||||||
|
| GET | `/api/monitor/settings` | 获取配置(密码脱敏) |
|
||||||
|
| PUT | `/api/monitor/settings` | 更新配置(支持分模块保存) |
|
||||||
|
| POST | `/api/monitor/test-mail` | 测试 IMAP 连接 |
|
||||||
|
| POST | `/api/monitor/test-wechat` | 测试微信 webhook |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 认证机制
|
## 认证机制
|
||||||
|
|
@ -258,9 +287,10 @@ NEXT_PUBLIC_ASSETS_URL=https://assets.tlyq.ai
|
||||||
ipAddress: getClientIP(request)
|
ipAddress: getClientIP(request)
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。两处必须遵守:
|
- **日期处理(时区规范)**:整个系统统一使用 UTC+8(北京时间)。必须遵守:
|
||||||
1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
1. **JavaScript/TypeScript**:禁止使用 `Date.toISOString()` 格式化本地日期。`toISOString()` 返回 UTC 时间,在中国时区(UTC+8)下 `new Date('2026-04-01T00:00:00').toISOString()` 会返回 `"2026-03-31T16:00:00.000Z"`,日期偏移一天。应使用本地时间方法拼接:`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||||
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 CREATE TABLE 的 DEFAULT 值、UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
|
2. **SQLite**:所有 `datetime('now')` 必须写成 `datetime('now', '+8 hours')`,包括 UPDATE/SET 语句、以及查询条件中的时间比较。禁止使用不含时区偏移的 `datetime('now')`。
|
||||||
|
3. **INSERT 时间列**:数据库表的 DEFAULT 已统一为 `datetime('now', '+8 hours')`,INSERT 时可省略时间列。如需显式设置,使用 `datetime('now', '+8 hours')`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
13
Dockerfile
13
Dockerfile
|
|
@ -7,6 +7,8 @@ RUN --mount=type=cache,target=/root/.npm,id=issue-npm \
|
||||||
npm ci
|
npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
# 编译 Worker 入口脚本 + monitor 模块 + db 模块为 JS(使用专用 tsconfig 解析 @/ 路径别名)
|
||||||
|
RUN npx tsc -p tsconfig.worker.json && npx tsc-alias -p tsconfig.worker.json
|
||||||
|
|
||||||
# runner 阶段:使用 Debian(glibc)以支持 better-sqlite3 等 glibc 原生模块
|
# runner 阶段:使用 Debian(glibc)以支持 better-sqlite3 等 glibc 原生模块
|
||||||
FROM node:20-slim AS runner
|
FROM node:20-slim AS runner
|
||||||
|
|
@ -40,6 +42,15 @@ RUN npm install --omit=dev && \
|
||||||
node_modules/@next/swc-linux-x64-musl
|
node_modules/@next/swc-linux-x64-musl
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
# 复制编译后的 Worker 文件(非 .ts 源码)
|
||||||
|
COPY --from=builder /app/.next/standalone/scripts/monitor-worker.js /app/scripts/
|
||||||
|
COPY --from=builder /app/.next/standalone/src/lib/monitor /app/src/lib/monitor
|
||||||
|
COPY --from=builder /app/.next/standalone/src/lib/db.js /app/src/lib/
|
||||||
|
COPY --from=builder /app/.next/standalone/src/lib/db-schema.js /app/src/lib/
|
||||||
|
COPY --from=builder /app/.next/standalone/src/lib/assets-client.js /app/src/lib/
|
||||||
RUN mkdir -p /app/data /app/uploads /app/reports
|
RUN mkdir -p /app/data /app/uploads /app/reports
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "server.js"]
|
# 使用 entrypoint.sh 同时启动 Next.js Server 和 Monitor Worker
|
||||||
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
CMD ["/app/entrypoint.sh"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
// __tests__/monitor/availability-engine.test.ts
|
||||||
|
import { AvailabilityEngine } from '../../src/lib/monitor/availability-engine'
|
||||||
|
|
||||||
|
// Mock getDb
|
||||||
|
jest.mock('../../src/lib/db', () => ({
|
||||||
|
getDb: () => ({
|
||||||
|
prepare: () => ({
|
||||||
|
get: () => ({ total: 0 }),
|
||||||
|
all: () => [],
|
||||||
|
run: () => {},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AvailabilityEngine', () => {
|
||||||
|
const engine = new AvailabilityEngine()
|
||||||
|
|
||||||
|
it('should calculate days in month correctly', () => {
|
||||||
|
expect(engine.getDaysInMonth('2026-03')).toBe(31)
|
||||||
|
expect(engine.getDaysInMonth('2026-02')).toBe(28)
|
||||||
|
expect(engine.getDaysInMonth('2026-04')).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate OEM diag deadline (first tier only)', () => {
|
||||||
|
const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01')
|
||||||
|
expect(deadline).toBeTruthy()
|
||||||
|
// 31 days = 2678400 seconds, 1% = 26784 seconds ≈ 7.44 hours
|
||||||
|
const fault = new Date('2026-03-10T16:44:01+08:00')
|
||||||
|
const deadlineStr = deadline as string
|
||||||
|
const deadlineDate = new Date(deadlineStr.replace(' ', 'T') + '+08:00')
|
||||||
|
const diffHours = (deadlineDate.getTime() - fault.getTime()) / (1000 * 3600)
|
||||||
|
expect(diffHours).toBeCloseTo(7.44, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate tier deadlines', () => {
|
||||||
|
const deadlines = engine.calculateTierDeadlines('2026-03', 'SN123', '2026-03-10 16:44:01')
|
||||||
|
expect(deadlines['第一档']).toBeTruthy()
|
||||||
|
expect(deadlines['第一档'].deadline).not.toBe('已超出')
|
||||||
|
expect(deadlines['第一档'].hours).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should format deadline in UTC+8', () => {
|
||||||
|
const deadline = engine.calculateOemDiagDeadline('2026-03', '2026-03-10 16:44:01')
|
||||||
|
// 应该是 UTC+8 格式,不是 UTC
|
||||||
|
expect(deadline).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# entrypoint.sh — 同时启动 Next.js Server 和 Monitor Worker
|
||||||
|
|
||||||
|
node /app/server.js &
|
||||||
|
SERVER_PID=$!
|
||||||
|
|
||||||
|
cd /app && node scripts/monitor-worker.js &
|
||||||
|
WORKER_PID=$!
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "Stopping Server (PID=$SERVER_PID) and Worker (PID=$WORKER_PID)..."
|
||||||
|
kill $SERVER_PID 2>/dev/null
|
||||||
|
kill $WORKER_PID 2>/dev/null
|
||||||
|
wait $SERVER_PID 2>/dev/null
|
||||||
|
wait $WORKER_PID 2>/dev/null
|
||||||
|
echo "Shutdown complete."
|
||||||
|
}
|
||||||
|
trap cleanup SIGTERM SIGINT
|
||||||
|
|
||||||
|
# 等待任一进程退出(需要 Bash 4.3+)
|
||||||
|
wait -n $SERVER_PID $WORKER_PID
|
||||||
|
EXIT_CODE=$?
|
||||||
|
cleanup
|
||||||
|
exit $EXIT_CODE
|
||||||
|
|
@ -10,14 +10,18 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"docx": "^9.1.1",
|
"docx": "^9.1.1",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
|
"imapflow": "^1.4.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"ldapts": "^8.1.7",
|
"ldapts": "^8.1.7",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"mailparser": "^3.9.11",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"node-cron": "^4.5.0",
|
||||||
"puppeteer": "^23.0.0",
|
"puppeteer": "^23.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
@ -1419,6 +1423,12 @@
|
||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pinojs/redact": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@puppeteer/browsers": {
|
"node_modules/@puppeteer/browsers": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.6.1.tgz",
|
||||||
|
|
@ -1467,6 +1477,22 @@
|
||||||
"streamx": "^2.15.0"
|
"streamx": "^2.15.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@selderee/plugin-htmlparser2": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "~2.3.0",
|
||||||
|
"domhandler": "~5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"selderee": "~0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
|
@ -1924,6 +1950,17 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@zone-eu/mailsplit": {
|
||||||
|
"version": "5.4.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.12.tgz",
|
||||||
|
"integrity": "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==",
|
||||||
|
"license": "(MIT OR EUPL-1.1+)",
|
||||||
|
"dependencies": {
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libmime": "5.3.8",
|
||||||
|
"libqp": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
|
|
@ -2024,6 +2061,15 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/atomic-sleep": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/b4a": {
|
"node_modules/b4a": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
|
||||||
|
|
@ -2219,6 +2265,12 @@
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
|
|
@ -2313,6 +2365,48 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cheerio": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"encoding-sniffer": "^0.2.1",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||||
|
"parse5-parser-stream": "^7.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
|
"whatwg-mimetype": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chownr": {
|
"node_modules/chownr": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
|
|
@ -2460,6 +2554,34 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
|
@ -2650,6 +2772,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge-ts": {
|
||||||
|
"version": "7.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/degenerator": {
|
"node_modules/degenerator": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
|
||||||
|
|
@ -2721,6 +2852,61 @@
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ecdsa-sig-formatter": {
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
|
@ -2752,6 +2938,28 @@
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-japanese": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/encoding-sniffer": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"whatwg-encoding": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
|
@ -2775,6 +2983,18 @@
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
|
|
@ -3304,6 +3524,65 @@
|
||||||
"minimalistic-assert": "^1.0.1"
|
"minimalistic-assert": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/he": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"he": "bin/he"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-to-text": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||||
|
"deepmerge-ts": "^7.1.5",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"selderee": "~0.12.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-proxy-agent": {
|
"node_modules/http-proxy-agent": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||||
|
|
@ -3330,6 +3609,18 @@
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
|
@ -3360,6 +3651,39 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/imapflow": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-73CGfb5+W0FkZ5CY4GfSdsoXyQ+17wdKkpMN2vwJHdLtOOFQWxv0ilG7KYY79XHBYg5njjqxXYB2FPw5Tl81zQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@zone-eu/mailsplit": "5.4.12",
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"iconv-lite": "0.7.2",
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libmime": "5.3.8",
|
||||||
|
"libqp": "2.1.1",
|
||||||
|
"nodemailer": "9.0.1",
|
||||||
|
"pino": "10.3.1",
|
||||||
|
"socks": "2.8.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/imapflow/node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immediate": {
|
"node_modules/immediate": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
|
@ -3414,9 +3738,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
|
|
@ -3605,6 +3929,15 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leac": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
|
|
@ -3619,6 +3952,46 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libbase64": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/libmime": {
|
||||||
|
"version": "5.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
|
||||||
|
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"iconv-lite": "0.7.2",
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libqp": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/libmime/node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/libqp": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lie": {
|
"node_modules/lie": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
|
@ -3907,6 +4280,25 @@
|
||||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|
@ -4011,6 +4403,40 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mailparser": {
|
||||||
|
"version": "3.9.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.11.tgz",
|
||||||
|
"integrity": "sha512-N1LPZwp1yVblJQukv5NAedlkHeA+PmZYdPCfk0Uwohn8JFOM8fYoUGF978qwlQcd3AlUleuzK0fu/EFAHPM9tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@zone-eu/mailsplit": "5.4.12",
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"he": "1.2.0",
|
||||||
|
"html-to-text": "10.0.0",
|
||||||
|
"iconv-lite": "0.7.2",
|
||||||
|
"libmime": "5.3.8",
|
||||||
|
"linkify-it": "5.0.1",
|
||||||
|
"nodemailer": "9.0.1",
|
||||||
|
"punycode.js": "2.3.1",
|
||||||
|
"tlds": "1.261.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mailparser/node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mimic-response": {
|
"node_modules/mimic-response": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||||
|
|
@ -4222,6 +4648,36 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-cron": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-4Trh+kjvbXokyJkwQumvD5YAgeJfgHLR/sKyu71uSmxfCR5QMO1hldpvmFZOICN5pLgNY+J5Y8+ar3XKo5/4tQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
@ -4231,6 +4687,15 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-exit-leak-free": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
|
@ -4358,6 +4823,68 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parseley": {
|
||||||
|
"version": "0.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
|
||||||
|
"integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"leac": "^0.7.0",
|
||||||
|
"peberminta": "^0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
|
|
@ -4378,6 +4905,15 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/peberminta": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pend": {
|
"node_modules/pend": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||||
|
|
@ -4390,6 +4926,43 @@
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/pino": {
|
||||||
|
"version": "10.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||||
|
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pinojs/redact": "^0.4.0",
|
||||||
|
"atomic-sleep": "^1.0.0",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^3.0.0",
|
||||||
|
"pino-std-serializers": "^7.0.0",
|
||||||
|
"process-warning": "^5.0.0",
|
||||||
|
"quick-format-unescaped": "^4.0.3",
|
||||||
|
"real-require": "^0.2.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"thread-stream": "^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-abstract-transport": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-std-serializers": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
|
|
@ -4481,6 +5054,22 @@
|
||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/process-warning": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/progress": {
|
"node_modules/progress": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
|
|
@ -4552,6 +5141,15 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/puppeteer": {
|
"node_modules/puppeteer": {
|
||||||
"version": "23.11.1",
|
"version": "23.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-23.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-23.11.1.tgz",
|
||||||
|
|
@ -4591,6 +5189,12 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quick-format-unescaped": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rc": {
|
"node_modules/rc": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
|
|
@ -4685,6 +5289,15 @@
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/real-require": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "2.15.4",
|
"version": "2.15.4",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||||
|
|
@ -4765,6 +5378,21 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-stable-stringify": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sax": {
|
"node_modules/sax": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||||
|
|
@ -4780,6 +5408,18 @@
|
||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/selderee": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parseley": "~0.13.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
|
@ -4922,12 +5562,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/socks": {
|
"node_modules/socks": {
|
||||||
"version": "2.8.7",
|
"version": "2.8.9",
|
||||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
|
||||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "^10.0.1",
|
"ip-address": "^10.1.1",
|
||||||
"smart-buffer": "^4.2.0"
|
"smart-buffer": "^4.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -4949,6 +5589,15 @@
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonic-boom": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"atomic-sleep": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
|
@ -4968,6 +5617,15 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ssf": {
|
"node_modules/ssf": {
|
||||||
"version": "0.11.2",
|
"version": "0.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
|
@ -5151,6 +5809,24 @@
|
||||||
"b4a": "^1.6.4"
|
"b4a": "^1.6.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thread-stream": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"real-require": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/thread-stream/node_modules/real-require": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/through": {
|
"node_modules/through": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
|
||||||
|
|
@ -5163,6 +5839,15 @@
|
||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tlds": {
|
||||||
|
"version": "1.261.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
|
||||||
|
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tlds": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|
@ -5234,6 +5919,12 @@
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unbzip2-stream": {
|
"node_modules/unbzip2-stream": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
|
||||||
|
|
@ -5244,6 +5935,15 @@
|
||||||
"through": "^2.3.8"
|
"through": "^2.3.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
|
||||||
|
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|
@ -5289,6 +5989,28 @@
|
||||||
"d3-timer": "^3.0.1"
|
"d3-timer": "^3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-encoding": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||||
|
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,18 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"docx": "^9.1.1",
|
"docx": "^9.1.1",
|
||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
|
"imapflow": "^1.4.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"ldapts": "^8.1.7",
|
"ldapts": "^8.1.7",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
|
"mailparser": "^3.9.11",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"node-cron": "^4.5.0",
|
||||||
"puppeteer": "^23.0.0",
|
"puppeteer": "^23.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|
@ -38,6 +42,7 @@
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tsc-alias": "^1.8.10",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
// scripts/migrate-monitor-data.ts
|
||||||
|
// 从 zabbix-01 拉取历史数据并迁移到 issue-ai SQLite
|
||||||
|
// 用法: ssh zabbix-01 "cat /opt/scripts/.../server_availability_data.json" | tsx scripts/migrate-monitor-data.ts
|
||||||
|
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
import { getDb } from '../src/lib/db'
|
||||||
|
import { initDatabase } from '../src/lib/db-schema'
|
||||||
|
|
||||||
|
initDatabase()
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
interface FaultRecord { fault_time: string; fault_type: string; order_number: string; recovery_time: string | null; duration: number | null; status: string }
|
||||||
|
interface ServerData { server_ip: string; fault_records: FaultRecord[]; total_fault_duration: number; ongoing_faults: string[] }
|
||||||
|
|
||||||
|
// 拉取 server_availability_data.json
|
||||||
|
console.log('从 zabbix-01 拉取数据...')
|
||||||
|
const availData = JSON.parse(execSync('ssh zabbix-01 "cat /opt/scripts/mail_monitor_wechat/data/server_availability_data.json"', { encoding: 'utf-8' }))
|
||||||
|
|
||||||
|
let migrated = 0, matched = 0, skipped = 0, errors = 0
|
||||||
|
|
||||||
|
for (const [monthKey, servers] of Object.entries(availData) as [string, Record<string, ServerData>][]) {
|
||||||
|
for (const [sn, serverData] of Object.entries(servers)) {
|
||||||
|
for (const record of serverData.fault_records) {
|
||||||
|
try {
|
||||||
|
// 检查工单是否存在
|
||||||
|
if (record.order_number) {
|
||||||
|
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(record.order_number))
|
||||||
|
if (!ticket) { skipped++; continue }
|
||||||
|
matched++
|
||||||
|
}
|
||||||
|
// 检查是否已存在
|
||||||
|
const existing = db.prepare('SELECT id FROM fault_records WHERE server_sn = ? AND fault_time = ?').get(sn, record.fault_time)
|
||||||
|
if (existing) { skipped++; continue }
|
||||||
|
|
||||||
|
const status = record.status === 'resolved' ? 'resolved' : 'ongoing'
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO fault_records (server_sn, server_ip, order_number, fault_type, fault_time, recovery_time, duration_seconds, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(sn, serverData.server_ip, record.order_number, record.fault_type, record.fault_time, record.recovery_time, record.duration, status)
|
||||||
|
migrated++
|
||||||
|
} catch (e) {
|
||||||
|
errors++
|
||||||
|
console.error(`迁移失败: ${sn} - ${record.fault_time}: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n迁移完成:`)
|
||||||
|
console.log(` 成功: ${migrated}`)
|
||||||
|
console.log(` 匹配工单: ${matched}`)
|
||||||
|
console.log(` 跳过: ${skipped}`)
|
||||||
|
console.log(` 错误: ${errors}`)
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
// scripts/monitor-worker.ts
|
||||||
|
const path = require('path')
|
||||||
|
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), 'data', 'issue.db')
|
||||||
|
process.env.DATABASE_PATH = dbPath
|
||||||
|
|
||||||
|
const { getDb } = require('../src/lib/db')
|
||||||
|
const { initDatabase } = require('../src/lib/db-schema')
|
||||||
|
const { BackgroundWorker } = require('../src/lib/monitor/worker')
|
||||||
|
const { setSetting, getSetting } = require('../src/lib/monitor/settings-manager')
|
||||||
|
|
||||||
|
initDatabase()
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
// 生成加密密钥(首次启动)
|
||||||
|
if (!getSetting('monitor.encryption_key')) {
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const newKey = crypto.randomBytes(32).toString('hex')
|
||||||
|
setSetting('monitor.encryption_key', newKey, 'security')
|
||||||
|
console.log('[Worker] 已生成加密密钥')
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = new BackgroundWorker()
|
||||||
|
if (getSetting('monitor.enabled') === 'true') {
|
||||||
|
worker.start()
|
||||||
|
console.log('[Worker] 邮件监控已启动')
|
||||||
|
} else {
|
||||||
|
console.log('[Worker] 邮件监控未启用,等待 API 启用')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保持进程存活,定期检查监控状态是否变更
|
||||||
|
const CHECK_INTERVAL = 30_000 // 30秒
|
||||||
|
setInterval(() => {
|
||||||
|
if (getSetting('monitor.enabled') === 'true' && !worker.cronJob) {
|
||||||
|
worker.start()
|
||||||
|
console.log('[Worker] 检测到监控已启用,自动启动')
|
||||||
|
} else if (getSetting('monitor.enabled') !== 'true' && worker.cronJob) {
|
||||||
|
worker.stop()
|
||||||
|
console.log('[Worker] 检测到监控已禁用,自动停止')
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL)
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('[Worker] 收到 SIGTERM,正在停止...')
|
||||||
|
worker.stop()
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Card, Button, Table, Badge, Modal } from '@/components/ui'
|
import Table, { Column } from '@/components/ui/Table'
|
||||||
|
import Button from '@/components/ui/Button'
|
||||||
|
import Modal from '@/components/ui/Modal'
|
||||||
|
import Badge from '@/components/ui/Badge'
|
||||||
|
|
||||||
interface AuditLog {
|
interface AuditLog {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -16,15 +19,15 @@ interface AuditLog {
|
||||||
api_key_name: string | null
|
api_key_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, { label: string; color: 'green' | 'blue' | 'red' | 'gray' }> = {
|
const ACTION_LABELS: Record<string, { label: string; variant: 'success' | 'info' | 'danger' | 'default' }> = {
|
||||||
create: { label: '创建', color: 'green' },
|
create: { label: '创建', variant: 'success' },
|
||||||
update: { label: '更新', color: 'blue' },
|
update: { label: '更新', variant: 'info' },
|
||||||
delete: { label: '删除', color: 'red' },
|
delete: { label: '删除', variant: 'danger' },
|
||||||
batch_update: { label: '批量更新', color: 'blue' },
|
batch_update: { label: '批量更新', variant: 'info' },
|
||||||
import: { label: '导入', color: 'blue' },
|
import: { label: '导入', variant: 'info' },
|
||||||
export: { label: '导出', color: 'gray' },
|
export: { label: '导出', variant: 'default' },
|
||||||
login: { label: '登录', color: 'gray' },
|
login: { label: '登录', variant: 'default' },
|
||||||
logout: { label: '登出', color: 'gray' },
|
logout: { label: '登出', variant: 'default' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENTITY_LABELS: Record<string, string> = {
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
|
|
@ -50,6 +53,14 @@ export default function AuditLogsPage() {
|
||||||
const [dateFrom, setDateFrom] = useState('')
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
const [dateTo, setDateTo] = useState('')
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
|
||||||
|
// 保留天数设置
|
||||||
|
const [retentionSaved, setRetentionSaved] = useState(180)
|
||||||
|
const [retentionInput, setRetentionInput] = useState('180')
|
||||||
|
const [retentionSaving, setRetentionSaving] = useState(false)
|
||||||
|
const [retentionSavedFeedback, setRetentionSavedFeedback] = useState(false)
|
||||||
|
const [retentionError, setRetentionError] = useState(false)
|
||||||
|
const retentionDirty = retentionInput !== String(retentionSaved)
|
||||||
|
|
||||||
// 详情 Modal
|
// 详情 Modal
|
||||||
const [detailLog, setDetailLog] = useState<AuditLog | null>(null)
|
const [detailLog, setDetailLog] = useState<AuditLog | null>(null)
|
||||||
|
|
||||||
|
|
@ -76,6 +87,49 @@ export default function AuditLogsPage() {
|
||||||
|
|
||||||
useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo])
|
useEffect(() => { fetchLogs() }, [page, actionFilter, entityFilter, dateFrom, dateTo])
|
||||||
|
|
||||||
|
async function fetchRetentionDays() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/audit-logs/cleanup')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setRetentionSaved(data.days)
|
||||||
|
setRetentionInput(String(data.days))
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRetentionDays() {
|
||||||
|
const days = parseInt(retentionInput, 10)
|
||||||
|
if (isNaN(days) || days < 30 || days > 365) {
|
||||||
|
setRetentionError(true)
|
||||||
|
setTimeout(() => setRetentionError(false), 2000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRetentionSaving(true)
|
||||||
|
setRetentionError(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/audit-logs/cleanup', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ days })
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setRetentionSaved(days)
|
||||||
|
setRetentionSavedFeedback(true)
|
||||||
|
setTimeout(() => setRetentionSavedFeedback(false), 2000)
|
||||||
|
} else {
|
||||||
|
setRetentionError(true)
|
||||||
|
setTimeout(() => setRetentionError(false), 2000)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRetentionError(true)
|
||||||
|
setTimeout(() => setRetentionError(false), 2000)
|
||||||
|
}
|
||||||
|
setRetentionSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchRetentionDays() }, [])
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
setActionFilter('')
|
setActionFilter('')
|
||||||
setEntityFilter('')
|
setEntityFilter('')
|
||||||
|
|
@ -107,7 +161,9 @@ export default function AuditLogsPage() {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`
|
const now = new Date()
|
||||||
|
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
|
a.download = `audit-logs-${dateStr}.csv`
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
@ -126,25 +182,310 @@ export default function AuditLogsPage() {
|
||||||
if (details.export) return `导出 ${details.export.count} 条`
|
if (details.export) return `导出 ${details.export.count} 条`
|
||||||
if (details.batch_update) return `批量更新 ${details.batch_update.count} 条`
|
if (details.batch_update) return `批量更新 ${details.batch_update.count} 条`
|
||||||
if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新`
|
if (details.import) return `导入 ${details.import.created} 新 / ${details.import.updated} 更新`
|
||||||
|
if (details.message) return details.message
|
||||||
|
if (details.keys) return details.keys.slice(0, 2).join(', ') + (details.keys.length > 2 ? '...' : '')
|
||||||
|
if (details.username) return `用户 ${details.username} 登录`
|
||||||
|
if (details.retention_days !== undefined) return `保留 ${details.retention_days} 天`
|
||||||
|
if (details.cleanup) return `清理 ${details.cleanup.deleted} 条`
|
||||||
return '-'
|
return '-'
|
||||||
} catch { return '-' }
|
} catch { return '-' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 字段名汉化映射
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
name: '名称', status: '状态', type: '类型', category: '分类',
|
||||||
|
title: '标题', priority: '优先级', assignee: '负责人', reporter: '报告人',
|
||||||
|
description: '描述', notes: '备注', email: '邮箱', role: '角色',
|
||||||
|
username: '用户名', permissions: '权限', count: '数量',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(val: unknown): string {
|
||||||
|
if (val === null || val === undefined) return '-'
|
||||||
|
if (typeof val === 'object') return JSON.stringify(val)
|
||||||
|
return String(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetails(details: Record<string, unknown>) {
|
||||||
|
// 字段变更(assets-ai 格式)
|
||||||
|
if (details.changes && typeof details.changes === 'object') {
|
||||||
|
const changes = details.changes as Record<string, { from: unknown; to: unknown }>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📋 修改了以下字段:</p>
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-100 dark:bg-slate-700">
|
||||||
|
<th className="px-3 py-2 text-left font-medium">字段</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">旧值</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">新值</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(changes).map(([key, val]) => (
|
||||||
|
<tr key={key} className="border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<td className="px-3 py-2 text-slate-500">{FIELD_LABELS[key] || key}</td>
|
||||||
|
<td className="px-3 py-2 text-slate-400 line-through">{formatValue(val.from)}</td>
|
||||||
|
<td className="px-3 py-2 text-green-600 dark:text-green-400 font-medium">{formatValue(val.to)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建记录(assets-ai 格式)
|
||||||
|
if (details.created && typeof details.created === 'object') {
|
||||||
|
const data = details.created as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-green-600 dark:text-green-400 mb-2">🟢 新建记录:</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(data).map(([key, val]) => (
|
||||||
|
<div key={key} className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">{FIELD_LABELS[key] || key}:</span>
|
||||||
|
<span>{formatValue(val)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除记录(assets-ai 格式)
|
||||||
|
if (details.deleted && typeof details.deleted === 'object') {
|
||||||
|
const data = details.deleted as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-red-600 dark:text-red-400 mb-2">🔴 删除记录:</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{Object.entries(data).map(([key, val]) => (
|
||||||
|
<div key={key} className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">{FIELD_LABELS[key] || key}:</span>
|
||||||
|
<span>{formatValue(val)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新(assets-ai 格式)
|
||||||
|
if (details.batch_update && typeof details.batch_update === 'object') {
|
||||||
|
const data = details.batch_update as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📦 批量更新 {String(data.count)} 条记录</p>
|
||||||
|
{data.ids ? <p className="text-sm text-slate-500">涉及 ID:{String(data.ids)}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入(assets-ai 格式)
|
||||||
|
if (details.import && typeof details.import === 'object') {
|
||||||
|
const data = details.import as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">📥 导入操作</p>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<span>新增:{String(data.created)} 条</span>
|
||||||
|
<span>更新:{String(data.updated)} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出(assets-ai 格式)
|
||||||
|
if (details.export && typeof details.export === 'object') {
|
||||||
|
const data = details.export as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400 mb-2">📤 导出 {String(data.count)} 条记录</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issue-ai 特有格式:操作说明
|
||||||
|
if (details.message && typeof details.message === 'string') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">📝 操作说明:</p>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">{details.message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issue-ai 特有格式:配置修改
|
||||||
|
if (details.keys && Array.isArray(details.keys)) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">⚙️ 修改了以下配置:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
{(details.keys as string[]).map((key, i) => (
|
||||||
|
<li key={i} className="text-sm text-slate-600 dark:text-slate-400 font-mono">{key}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录信息
|
||||||
|
if (details.username && typeof details.username === 'string') {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">👤 登录信息:</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">用户名:</span>
|
||||||
|
<span>{details.username}</span>
|
||||||
|
</div>
|
||||||
|
{details.role && (
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">角色:</span>
|
||||||
|
<span>{String(details.role)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{details.displayName && (
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<span className="text-slate-500">显示名:</span>
|
||||||
|
<span>{String(details.displayName)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留天数设置
|
||||||
|
if (details.retention_days !== undefined) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-600 dark:text-blue-400 mb-2">⚙️ 保留天数设置:</p>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">日志保留期限调整为 <span className="font-medium">{String(details.retention_days)}</span> 天</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理操作
|
||||||
|
if (details.cleanup) {
|
||||||
|
const data = details.cleanup as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">🧹 清理操作:</p>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">已清理 {String(data.deleted)} 条过期日志</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:格式化 JSON
|
||||||
|
return (
|
||||||
|
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
|
||||||
|
{JSON.stringify(details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
title: '时间',
|
||||||
|
width: '170px',
|
||||||
|
render: (r) => <span className="text-sm whitespace-nowrap">{r.created_at}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'username',
|
||||||
|
title: '用户',
|
||||||
|
width: '100px',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<span>{r.username || '-'}</span>
|
||||||
|
{r.api_key_id && <Badge variant="warning">[API]</Badge>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
title: '操作',
|
||||||
|
width: '70px',
|
||||||
|
render: (r) => {
|
||||||
|
const info = ACTION_LABELS[r.action] || { label: r.action, variant: 'default' as const }
|
||||||
|
return <span className="whitespace-nowrap"><Badge variant={info.variant}>{info.label}</Badge></span>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'entity_type',
|
||||||
|
title: '对象',
|
||||||
|
width: '80px',
|
||||||
|
render: (r) => <span className="whitespace-nowrap">{ENTITY_LABELS[r.entity_type] || r.entity_type}</span>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'details',
|
||||||
|
title: '详情',
|
||||||
|
render: (r) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailLog(r)}
|
||||||
|
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
|
||||||
|
>
|
||||||
|
{formatDetails(r)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ip_address',
|
||||||
|
title: 'IP',
|
||||||
|
width: '120px',
|
||||||
|
render: (r) => <span className="text-xs font-mono">{r.ip_address || '-'}</span>
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 保留天数按钮样式
|
||||||
|
const retentionBtnClass = retentionSaved
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: retentionError
|
||||||
|
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||||
|
: ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">审计日志</h1>
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">审计日志</h1>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">系统操作与事件记录,保留 180 天</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">系统操作与事件记录</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" onClick={handleExport}>导出</Button>
|
<Button size="sm" variant="secondary" onClick={handleExport}>导出</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 保留天数设置 */}
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800/50 rounded-lg text-sm">
|
||||||
|
<span className="text-slate-600 dark:text-slate-400">保留天数:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
max={365}
|
||||||
|
value={retentionInput}
|
||||||
|
onChange={(e) => setRetentionInput(e.target.value)}
|
||||||
|
className="w-20 px-2 py-1 text-sm border border-slate-200 dark:border-slate-700 rounded bg-white dark:bg-slate-900"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">(30-365 天,默认 180 天)</span>
|
||||||
|
{retentionSavedFeedback ? (
|
||||||
|
<span className="text-xs font-medium text-green-600 dark:text-green-400">✓ 已保存</span>
|
||||||
|
) : retentionError ? (
|
||||||
|
<span className="text-xs font-medium text-red-600 dark:text-red-400">✗ 保存失败</span>
|
||||||
|
) : retentionDirty ? (
|
||||||
|
<button
|
||||||
|
className="px-3 py-1 text-xs font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||||
|
onClick={saveRetentionDays}
|
||||||
|
disabled={retentionSaving}
|
||||||
|
>
|
||||||
|
{retentionSaving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 过滤器 */}
|
{/* 过滤器 */}
|
||||||
<Card className="p-4">
|
<div className="flex flex-wrap items-center gap-3 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<select
|
<select
|
||||||
value={actionFilter}
|
value={actionFilter}
|
||||||
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
|
onChange={(e) => { setActionFilter(e.target.value); setPage(1) }}
|
||||||
|
|
@ -189,55 +530,20 @@ export default function AuditLogsPage() {
|
||||||
|
|
||||||
<Button size="sm" variant="ghost" onClick={resetFilters}>重置</Button>
|
<Button size="sm" variant="ghost" onClick={resetFilters}>重置</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 表格 */}
|
{/* 表格 */}
|
||||||
<Card>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-12 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
<div className="py-12 text-center text-slate-500 dark:text-slate-400">加载中...</div>
|
||||||
) : (
|
) : (
|
||||||
<Table headers={['时间', '用户', '操作', '对象', '详情', 'IP']}>
|
<Table columns={columns as any} data={logs as any} rowKey={(r: any) => String(r.id)} />
|
||||||
{logs.map((log) => (
|
|
||||||
<tr key={log.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
|
||||||
<td className="px-4 py-3 text-sm">{log.created_at}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span>{log.username || '-'}</span>
|
|
||||||
{log.api_key_id && <Badge variant="warning">[API]</Badge>}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge variant={ACTION_LABELS[log.action]?.color === 'green' ? 'success' : ACTION_LABELS[log.action]?.color === 'blue' ? 'info' : ACTION_LABELS[log.action]?.color === 'red' ? 'danger' : 'default'}>
|
|
||||||
{ACTION_LABELS[log.action]?.label || log.action}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">{ENTITY_LABELS[log.entity_type] || log.entity_type}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setDetailLog(log)}
|
|
||||||
className="text-left text-sm text-slate-600 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 truncate max-w-[200px]"
|
|
||||||
>
|
|
||||||
{formatDetails(log)}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-xs font-mono">{log.ip_address || '-'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{logs.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-12 text-center text-slate-500">暂无数据</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
)}
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
{total > pageSize && (
|
{total > pageSize && (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="ghost"
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
onClick={() => setPage(p => p - 1)}
|
onClick={() => setPage(p => p - 1)}
|
||||||
>
|
>
|
||||||
|
|
@ -248,7 +554,7 @@ export default function AuditLogsPage() {
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="ghost"
|
||||||
disabled={page >= Math.ceil(total / pageSize)}
|
disabled={page >= Math.ceil(total / pageSize)}
|
||||||
onClick={() => setPage(p => p + 1)}
|
onClick={() => setPage(p => p + 1)}
|
||||||
>
|
>
|
||||||
|
|
@ -273,11 +579,11 @@ export default function AuditLogsPage() {
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-500">用户:</span>
|
<span className="text-slate-500">用户:</span>
|
||||||
<span>{detailLog.username || '-'}</span>
|
<span>{detailLog.username || '-'}</span>
|
||||||
{detailLog.api_key_id && <Badge variant="warning" className="ml-1">[API]</Badge>}
|
{detailLog.api_key_id && <span className="ml-1"><Badge variant="warning">[API]</Badge></span>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-500">操作:</span>
|
<span className="text-slate-500">操作:</span>
|
||||||
<Badge variant={ACTION_LABELS[detailLog.action]?.color === 'green' ? 'success' : ACTION_LABELS[detailLog.action]?.color === 'blue' ? 'info' : ACTION_LABELS[detailLog.action]?.color === 'red' ? 'danger' : 'default'}>
|
<Badge variant={ACTION_LABELS[detailLog.action]?.variant || 'default'}>
|
||||||
{ACTION_LABELS[detailLog.action]?.label || detailLog.action}
|
{ACTION_LABELS[detailLog.action]?.label || detailLog.action}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -293,11 +599,8 @@ export default function AuditLogsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detailLog.details && (
|
{detailLog.details && (
|
||||||
<div>
|
<div className="mt-3 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
<span className="text-sm text-slate-500">详情:</span>
|
{renderDetails(JSON.parse(detailLog.details))}
|
||||||
<pre className="mt-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg text-xs font-mono overflow-x-auto">
|
|
||||||
{JSON.stringify(JSON.parse(detailLog.details), null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,471 @@
|
||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Card } from '@/components/ui'
|
||||||
|
|
||||||
|
interface MonitorStatus {
|
||||||
|
enabled: boolean
|
||||||
|
status: string
|
||||||
|
workerHealth: string
|
||||||
|
lastCheck: string | null
|
||||||
|
stats: { totalProcessed: number; todayProcessed: number; errors: number; lastError: string | null; lastErrorTime: string | null }
|
||||||
|
ongoingFaults: number
|
||||||
|
details: {
|
||||||
|
todayProcessed: { ticket_no?: string; error?: string; created_at: string }[]
|
||||||
|
ongoingFaults: { id: number; server_sn: string; server_ip: string | null; order_number: string | null; fault_type: string | null; fault_time: string; fault_detail: string | null }[]
|
||||||
|
recentErrors: { error?: string; details?: string; created_at: string }[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonitorConfig {
|
||||||
|
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
|
||||||
|
mail: { address: string; imap_server: string; imap_port: number; smtp_server: string; smtp_port: number; password: string }
|
||||||
|
filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } }
|
||||||
|
wechat: { webhook_url: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor'
|
||||||
|
|
||||||
|
// 深比较两个对象,返回变化的字段路径
|
||||||
|
function getChangedFields(original: unknown, current: unknown, prefix = ''): string[] {
|
||||||
|
const changes: string[] = []
|
||||||
|
if (original === current) return changes
|
||||||
|
if (typeof original !== typeof current) { changes.push(prefix); return changes }
|
||||||
|
if (typeof original !== 'object' || original === null || current === null) {
|
||||||
|
if (original !== current) changes.push(prefix)
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
const orig = original as Record<string, unknown>
|
||||||
|
const curr = current as Record<string, unknown>
|
||||||
|
const allKeys = new Set([...Object.keys(orig), ...Object.keys(curr)])
|
||||||
|
for (const key of allKeys) {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key
|
||||||
|
if (JSON.stringify(orig[key]) !== JSON.stringify(curr[key])) {
|
||||||
|
changes.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查某个 section 是否有变化
|
||||||
|
function hasSectionChanged(original: MonitorConfig | null, current: MonitorConfig | null, section: SectionKey): boolean {
|
||||||
|
if (!original || !current) return false
|
||||||
|
return JSON.stringify(original[section]) !== JSON.stringify(current[section])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitorSettingsPage() {
|
||||||
|
const [status, setStatus] = useState<MonitorStatus | null>(null)
|
||||||
|
const [config, setConfig] = useState<MonitorConfig | null>(null)
|
||||||
|
const [originalConfig, setOriginalConfig] = useState<MonitorConfig | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState<SectionKey | null>(null)
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
const [newKeyword, setNewKeyword] = useState('')
|
||||||
|
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
|
||||||
|
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/monitor/status').then(r => r.json()),
|
||||||
|
fetch('/api/monitor/settings').then(r => r.json()),
|
||||||
|
]).then(([s, c]) => {
|
||||||
|
setStatus(s)
|
||||||
|
setConfig(c)
|
||||||
|
setOriginalConfig(JSON.parse(JSON.stringify(c)))
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshStatus = () => {
|
||||||
|
fetch('/api/monitor/status').then(r => r.json()).then(setStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStart = async () => {
|
||||||
|
await fetch('/api/monitor/start', { method: 'POST' })
|
||||||
|
refreshStatus()
|
||||||
|
setMessage({ type: 'success', text: '监控已启用' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
if (!confirm('确认停用邮件监控?')) return
|
||||||
|
await fetch('/api/monitor/stop', { method: 'POST' })
|
||||||
|
refreshStatus()
|
||||||
|
setMessage({ type: 'success', text: '监控已停用' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTrigger = async () => {
|
||||||
|
const res = await fetch('/api/monitor/trigger', { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
setMessage({ type: 'success', text: data.message })
|
||||||
|
setTimeout(refreshStatus, 2000)
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: data.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTestMail = async () => {
|
||||||
|
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
|
||||||
|
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTestWechat = async () => {
|
||||||
|
setSectionMessage(prev => ({ ...prev, wechat: { type: 'success', text: '正在测试微信推送...' } }))
|
||||||
|
const res = await fetch('/api/monitor/test-wechat', { method: 'POST' })
|
||||||
|
const data = await res.json()
|
||||||
|
setSectionMessage(prev => ({ ...prev, wechat: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSectionSave = async (section: SectionKey) => {
|
||||||
|
if (!config) return
|
||||||
|
setSaving(section)
|
||||||
|
setSectionMessage(prev => ({ ...prev, [section]: undefined }))
|
||||||
|
const sectionData = { [section]: config[section] }
|
||||||
|
const res = await fetch('/api/monitor/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sectionData) })
|
||||||
|
const data = await res.json()
|
||||||
|
setSaving(null)
|
||||||
|
if (data.success) {
|
||||||
|
setOriginalConfig(prev => prev ? { ...prev, [section]: JSON.parse(JSON.stringify(config[section])) } : prev)
|
||||||
|
setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } }))
|
||||||
|
} else {
|
||||||
|
setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } }))
|
||||||
|
}
|
||||||
|
setTimeout(() => setSectionMessage(prev => ({ ...prev, [section]: undefined })), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addKeyword = () => {
|
||||||
|
if (!config || !newKeyword.trim()) return
|
||||||
|
setConfig({ ...config, filter: { ...config.filter, subject_keywords: [...config.filter.subject_keywords, newKeyword.trim()] } })
|
||||||
|
setNewKeyword('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeKeyword = (index: number) => {
|
||||||
|
if (!config) return
|
||||||
|
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取某个字段是否被修改
|
||||||
|
const isFieldChanged = (path: string): boolean => {
|
||||||
|
if (!originalConfig || !config) return false
|
||||||
|
const changes = getChangedFields(originalConfig, config)
|
||||||
|
return changes.some(c => c === path || c.startsWith(path + '.'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框样式:高亮修改的字段
|
||||||
|
const inputClass = (path: string) =>
|
||||||
|
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${isFieldChanged(path) ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
|
||||||
|
|
||||||
|
// Section 保存按钮组件(消息反馈由各 section 自行渲染,此处只渲染按钮)
|
||||||
|
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
||||||
|
const changed = hasSectionChanged(originalConfig, config, section)
|
||||||
|
if (!changed) return null
|
||||||
|
return (
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSectionSave(section)}
|
||||||
|
disabled={saving === section}
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{saving === section ? '保存中...' : '保存设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="p-6">加载中...</div>
|
||||||
|
if (!config) return <div className="p-6">加载失败</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">邮件监控设置</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 mt-1">配置邮件监控、微信推送与提醒规则</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
|
||||||
|
{message.text}
|
||||||
|
<button onClick={() => setMessage(null)} className="float-right font-bold">×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 运行状态 */}
|
||||||
|
<Card className="p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">运行状态</h2>
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium ${status?.workerHealth === 'healthy' ? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400' : status?.workerHealth === 'error' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : status?.workerHealth === 'unresponsive' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${status?.workerHealth === 'healthy' ? 'bg-green-500' : status?.workerHealth === 'error' || status?.workerHealth === 'unresponsive' ? 'bg-red-500' : 'bg-slate-400'}`} />
|
||||||
|
{status?.workerHealth === 'healthy' ? '运行中' : status?.workerHealth === 'error' ? '运行异常' : status?.workerHealth === 'unresponsive' ? '无响应' : '已停止'}
|
||||||
|
</span>
|
||||||
|
{status?.lastCheck && <span className="text-sm text-slate-500">上次检查: {status.lastCheck}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计指标 - 可点击展开 */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-4">
|
||||||
|
{[
|
||||||
|
{ key: 'today' as const, label: '今日处理', value: status?.stats.todayProcessed || 0, color: 'text-blue-600 dark:text-blue-400' },
|
||||||
|
{ key: 'total' as const, label: '累计', value: status?.stats.totalProcessed || 0, color: 'text-slate-700 dark:text-slate-300' },
|
||||||
|
{ key: 'errors' as const, label: '错误', value: status?.stats.errors || 0, color: 'text-red-600 dark:text-red-400' },
|
||||||
|
{ key: 'ongoing' as const, label: '进行中故障', value: status?.ongoingFaults || 0, color: 'text-amber-600 dark:text-amber-400' },
|
||||||
|
].map(item => (
|
||||||
|
<div key={item.key}>
|
||||||
|
<span className="text-slate-500 text-sm">{item.label}: </span>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedPanel(expandedPanel === item.key ? null : item.key)}
|
||||||
|
className={`font-medium text-sm cursor-pointer hover:opacity-70 underline decoration-dotted underline-offset-2 transition-opacity ${item.color}`}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
<span className="ml-1 text-xs">{expandedPanel === item.key ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 展开面板 */}
|
||||||
|
{expandedPanel && (
|
||||||
|
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||||
|
{/* 今日处理详情 */}
|
||||||
|
{expandedPanel === 'today' && status?.details?.todayProcessed && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">今日处理记录</span>
|
||||||
|
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||||||
|
</div>
|
||||||
|
{status.details.todayProcessed.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">今日暂无处理记录</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{status.details.todayProcessed.map((item, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-sm py-1 border-b border-slate-100 dark:border-slate-800 last:border-0">
|
||||||
|
<span className="text-slate-700 dark:text-slate-300">
|
||||||
|
{item.ticket_no ? `工单 ${item.ticket_no}` : item.error || '处理完成'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400 text-xs">{item.created_at}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 累计统计详情 */}
|
||||||
|
{expandedPanel === 'total' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">累计统计</span>
|
||||||
|
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
|
<span className="text-slate-500">总处理工单</span>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{status?.stats.totalProcessed || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
|
<span className="text-slate-500">今日处理</span>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{status?.stats.todayProcessed || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
|
<span className="text-slate-500">总错误数</span>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{status?.stats.errors || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
|
<span className="text-slate-500">进行中故障</span>
|
||||||
|
<p className="text-2xl font-bold text-amber-600">{status?.ongoingFaults || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误详情 */}
|
||||||
|
{expandedPanel === 'errors' && status?.details?.recentErrors && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-medium text-red-700 dark:text-red-400">最近错误记录</span>
|
||||||
|
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||||||
|
</div>
|
||||||
|
{status.details.recentErrors.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">暂无错误记录</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{status.details.recentErrors.map((item, i) => (
|
||||||
|
<div key={i} className="p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-red-700 dark:text-red-400 font-medium">错误</span>
|
||||||
|
<span className="text-slate-400">{item.created_at}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
|
||||||
|
{(() => {
|
||||||
|
const errStr = item.error || item.details || JSON.stringify(item)
|
||||||
|
try { return JSON.stringify(JSON.parse(errStr), null, 2) }
|
||||||
|
catch { return errStr }
|
||||||
|
})()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 进行中故障详情 */}
|
||||||
|
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">进行中故障列表</span>
|
||||||
|
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||||||
|
</div>
|
||||||
|
{status.details.ongoingFaults.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">暂无进行中的故障</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-200 dark:border-slate-700 text-left">
|
||||||
|
<th className="py-2 pr-4 text-slate-500 font-medium">工单号</th>
|
||||||
|
<th className="py-2 pr-4 text-slate-500 font-medium">服务器IP</th>
|
||||||
|
<th className="py-2 pr-4 text-slate-500 font-medium">SN</th>
|
||||||
|
<th className="py-2 pr-4 text-slate-500 font-medium">类型</th>
|
||||||
|
<th className="py-2 pr-4 text-slate-500 font-medium">故障时间</th>
|
||||||
|
<th className="py-2 text-slate-500 font-medium">故障信息</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{status.details.ongoingFaults.map((fault, i) => (
|
||||||
|
<tr key={i} className="border-b border-slate-100 dark:border-slate-800 last:border-0">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">{fault.order_number || '-'}</td>
|
||||||
|
<td className="py-2 pr-4">{fault.server_ip || '未知'}</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">{fault.server_sn}</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${fault.fault_type === 'oem_diag' ? 'bg-blue-100 text-blue-700' : fault.fault_type === 'oem_repair' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||||
|
{fault.fault_type === 'oem_diag' ? '诊断' : fault.fault_type === 'oem_repair' ? '维修' : '未知'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs">{fault.fault_time}</td>
|
||||||
|
<td className="py-2 text-xs max-w-xs truncate" title={fault.fault_detail || ''}>{fault.fault_detail || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{status?.enabled ? <button onClick={handleStop} className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm">停止监控</button> : <button onClick={handleStart} className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm">启动监控</button>}
|
||||||
|
<button onClick={handleTrigger} className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm">立即检查</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 邮箱配置 */}
|
||||||
|
<Card className="p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">邮箱配置</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">邮箱地址</label>
|
||||||
|
<input type="text" value={config.mail.address} onChange={e => setConfig({ ...config, mail: { ...config.mail, address: e.target.value } })} className={inputClass('mail.address')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">邮箱密码</label>
|
||||||
|
<input type="password" value={config.mail.password} onChange={e => setConfig({ ...config, mail: { ...config.mail, password: e.target.value } })} className={inputClass('mail.password')} placeholder="••••••••" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">IMAP 服务器</label>
|
||||||
|
<input type="text" value={config.mail.imap_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_server: e.target.value } })} className={inputClass('mail.imap_server')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">IMAP 端口</label>
|
||||||
|
<input type="number" value={config.mail.imap_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">SMTP 服务器</label>
|
||||||
|
<input type="text" value={config.mail.smtp_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">SMTP 端口</label>
|
||||||
|
<input type="number" value={config.mail.smtp_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_port: parseInt(e.target.value) || 465 } })} className={inputClass('mail.smtp_port')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button onClick={handleTestMail} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm">测试邮箱连接</button>
|
||||||
|
{sectionMessage.mail && (
|
||||||
|
<span className={`text-sm ${sectionMessage.mail.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||||
|
{sectionMessage.mail.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SectionSaveButton section="mail" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 邮件过滤 */}
|
||||||
|
<Card className="p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">邮件过滤</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">主题关键词(匹配任一即通过)</label>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{config.filter.subject_keywords.map((kw, i) => (
|
||||||
|
<span key={i} className={`inline-flex items-center gap-1 px-2 py-1 rounded text-sm ${isFieldChanged('filter.subject_keywords') ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'}`}>
|
||||||
|
{kw}
|
||||||
|
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input type="text" value={newKeyword} onChange={e => setNewKeyword(e.target.value)} onKeyDown={e => e.key === 'Enter' && addKeyword()} className="flex-1 px-3 py-2 border rounded-lg text-sm" placeholder="输入关键词后回车" />
|
||||||
|
<button onClick={addKeyword} className="px-3 py-2 bg-blue-500 text-white rounded-lg text-sm">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">发件人邮箱</label>
|
||||||
|
<input type="text" value={config.filter.sender_email} onChange={e => setConfig({ ...config, filter: { ...config.filter, sender_email: e.target.value } })} className={inputClass('filter.sender_email')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SectionSaveButton section="filter" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 微信推送 */}
|
||||||
|
<Card className="p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">微信推送</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Webhook 地址</label>
|
||||||
|
<input type="text" value={config.wechat.webhook_url} onChange={e => setConfig({ ...config, wechat: { ...config.wechat, webhook_url: e.target.value } })} className={inputClass('wechat.webhook_url')} placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button onClick={handleTestWechat} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm">测试微信推送</button>
|
||||||
|
{sectionMessage.wechat && (
|
||||||
|
<span className={`text-sm ${sectionMessage.wechat.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||||
|
{sectionMessage.wechat.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SectionSaveButton section="wechat" />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 运行参数 */}
|
||||||
|
<Card className="p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">运行参数</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">检查间隔(秒)</label>
|
||||||
|
<input type="number" value={config.monitor.interval_seconds} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, interval_seconds: parseInt(e.target.value) || 60 } })} className={inputClass('monitor.interval_seconds')} min={10} max={3600} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">静默期开始(时)</label>
|
||||||
|
<input type="number" value={config.monitor.silent_start_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_start_hour: parseInt(e.target.value) || 0 } })} className={inputClass('monitor.silent_start_hour')} min={0} max={23} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">静默期结束(时)</label>
|
||||||
|
<input type="number" value={config.monitor.silent_end_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_end_hour: parseInt(e.target.value) || 7 } })} className={inputClass('monitor.silent_end_hour')} min={0} max={23} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SectionSaveButton section="monitor" />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Card } from '@/components/ui'
|
import { Card } from '@/components/ui'
|
||||||
import { Users, Shield, Settings as SettingsIcon } from 'lucide-react'
|
import { Users, Shield, Settings as SettingsIcon, Mail } from 'lucide-react'
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{ href: '/settings/users', label: '用户管理', description: '管理系统用户与账号', icon: Users },
|
{ href: '/settings/users', label: '用户管理', description: '管理系统用户与账号', icon: Users },
|
||||||
{ href: '/settings/roles', label: '角色权限', description: '配置角色与权限', icon: Shield },
|
{ href: '/settings/roles', label: '角色权限', description: '配置角色与权限', icon: Shield },
|
||||||
|
{ href: '/settings/monitor', label: '邮件监控', description: '邮件监控与微信推送配置', icon: Mail },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,17 @@ export default function TicketDetailPage() {
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!confirm('确定要删除此工单吗?')) return
|
if (!confirm('确定要删除此工单吗?')) return
|
||||||
|
try {
|
||||||
const res = await fetch(`/api/tickets/${ticket.id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/tickets/${ticket.id}`, { method: 'DELETE' })
|
||||||
if (res.ok) handleBack()
|
const data = await res.json()
|
||||||
|
if (res.ok) {
|
||||||
|
handleBack()
|
||||||
|
} else {
|
||||||
|
alert(data.error || '删除失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('删除失败:网络错误')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const isPending = (ticket.current_status === 'open' || ticket.current_status === 'in_progress') && !ticket.close_time
|
const isPending = (ticket.current_status === 'open' || ticket.current_status === 'in_progress') && !ticket.close_time
|
||||||
const isAdmin = currentUser?.role === 'admin'
|
const isAdmin = currentUser?.role === 'admin'
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,56 @@ import { getCurrentUser } from '@/lib/auth'
|
||||||
import { initDatabase } from '@/lib/db-schema'
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
// 获取保留天数配置
|
||||||
|
export async function GET() {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||||
|
).get() as { value: string } | undefined
|
||||||
|
|
||||||
|
return NextResponse.json({ days: parseInt(row?.value || '180', 10) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新保留天数配置
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const body = await request.json()
|
||||||
|
const days = Math.max(30, Math.min(365, parseInt(body.days, 10)))
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_retention_days', ?)"
|
||||||
|
).run(String(days))
|
||||||
|
|
||||||
|
writeAuditLog({
|
||||||
|
userId: user.id,
|
||||||
|
action: 'update',
|
||||||
|
entityType: 'audit_log',
|
||||||
|
details: { retention_days: days },
|
||||||
|
ipAddress: getClientIP(request)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ days })
|
||||||
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
initDatabase()
|
initDatabase()
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
if (!user) return NextResponse.json({ error: '未登录' }, { status: 401 })
|
||||||
|
|
||||||
// 检查权限
|
|
||||||
if (user.role !== 'admin') {
|
if (user.role !== 'admin') {
|
||||||
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
|
if (!user.permissions?.includes('*') && !user.permissions?.includes('audit-logs:export')) {
|
||||||
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
|
@ -18,23 +62,23 @@ export async function DELETE(request: NextRequest) {
|
||||||
|
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
|
|
||||||
// 清理 180 天前的日志
|
// 从 settings 读取保留天数
|
||||||
|
const retentionRow = db.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||||
|
).get() as { value: string } | undefined
|
||||||
|
const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10)))
|
||||||
|
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
"DELETE FROM audit_logs WHERE created_at < datetime('now', '-180 days', '+8 hours')"
|
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
|
||||||
).run()
|
).run()
|
||||||
|
|
||||||
// 自审计:记录清理操作
|
|
||||||
writeAuditLog({
|
writeAuditLog({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
action: 'cleanup',
|
action: 'cleanup',
|
||||||
entityType: 'audit_log',
|
entityType: 'audit_log',
|
||||||
details: {
|
details: { cleanup: { deleted: result.changes, retention_days: retentionDays } },
|
||||||
cleanup: {
|
|
||||||
deleted: result.changes
|
|
||||||
}
|
|
||||||
},
|
|
||||||
ipAddress: getClientIP(request)
|
ipAddress: getClientIP(request)
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ deleted: result.changes })
|
return NextResponse.json({ deleted: result.changes, retention_days: retentionDays })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
// src/app/api/monitor/settings/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { getMaskedConfig, updateMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
const VALIDATORS: Record<string, (v: unknown) => boolean> = {
|
||||||
|
'monitor.enabled': (v) => typeof v === 'boolean',
|
||||||
|
'monitor.interval_seconds': (v) => typeof v === 'number' && v >= 10 && v <= 3600,
|
||||||
|
'monitor.push_delay_ms': (v) => typeof v === 'number' && v >= 0 && v <= 10000,
|
||||||
|
'monitor.silent_start_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
|
||||||
|
'monitor.silent_end_hour': (v) => typeof v === 'number' && v >= 0 && v <= 23,
|
||||||
|
'mail.address': (v) => typeof v === 'string' && v.includes('@'),
|
||||||
|
'mail.imap_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||||
|
'mail.smtp_port': (v) => typeof v === 'number' && v > 0 && v <= 65535,
|
||||||
|
'wechat.webhook_url': (v) => typeof v === 'string' && (v === '' || v.startsWith('https://qyapi.weixin.qq.com/')),
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:read')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
return NextResponse.json(getMaskedConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
const body = await request.json()
|
||||||
|
// 保留未修改的密码
|
||||||
|
if (body.mail?.password === '••••••••' || body.mail?.password === undefined) {
|
||||||
|
delete body.mail?.password
|
||||||
|
}
|
||||||
|
// 先展平嵌套对象
|
||||||
|
const flat: Record<string, unknown> = {}
|
||||||
|
for (const [section, values] of Object.entries(body)) {
|
||||||
|
if (typeof values === 'object' && values !== null) {
|
||||||
|
for (const [key, value] of Object.entries(values as Record<string, unknown>)) {
|
||||||
|
flat[`${section}.${key}`] = value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
flat[section] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 展平后验证
|
||||||
|
for (const [key, value] of Object.entries(flat)) {
|
||||||
|
const validator = VALIDATORS[key]
|
||||||
|
if (validator && !validator(value)) return NextResponse.json({ error: `参数 ${key} 验证失败` }, { status: 400 })
|
||||||
|
}
|
||||||
|
updateMonitorConfig(flat)
|
||||||
|
writeAuditLog({ userId: user.id, action: 'update', entityType: 'monitor_config', details: { keys: Object.keys(flat) }, ipAddress: getClientIP(request) })
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
// src/app/api/monitor/start/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { setSetting } from '@/lib/monitor/settings-manager'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
setSetting('monitor.enabled', 'true', 'monitor')
|
||||||
|
writeAuditLog({ userId: user.id, action: 'enable', entityType: 'monitor', details: { message: '启用邮件监控' }, ipAddress: getClientIP(request) })
|
||||||
|
return NextResponse.json({ success: true, message: '监控已启用,Worker 将在下一个 tick 启动' })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
// src/app/api/monitor/status/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { getSetting } from '@/lib/monitor/settings-manager'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:read')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
const db = getDb()
|
||||||
|
const enabled = getSetting('monitor.enabled') === 'true'
|
||||||
|
const lastTick = db.prepare("SELECT created_at, details FROM monitor_logs WHERE action = 'tick_complete' ORDER BY id DESC LIMIT 1").get() as { created_at: string; details: string } | undefined
|
||||||
|
|
||||||
|
// 使用 UTC+8 日期(数据库中 created_at 是 UTC+8)
|
||||||
|
const now = new Date()
|
||||||
|
const todayStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} 00:00:00`
|
||||||
|
const todayProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ?").get(todayStart) as { c: number }).c
|
||||||
|
const totalProcessed = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'ticket_created'").get() as { c: number }).c
|
||||||
|
const errorCount = (db.prepare("SELECT COUNT(*) as c FROM monitor_logs WHERE action = 'error'").get() as { c: number }).c
|
||||||
|
const ongoingFaultsCount = (db.prepare("SELECT COUNT(*) as c FROM fault_records WHERE status = 'ongoing'").get() as { c: number }).c
|
||||||
|
|
||||||
|
// 获取最近的活动(tick_complete 或 error)
|
||||||
|
const lastActivity = db.prepare("SELECT created_at, action FROM monitor_logs WHERE action IN ('tick_complete', 'error') ORDER BY id DESC LIMIT 1").get() as { created_at: string; action: string } | undefined
|
||||||
|
|
||||||
|
let workerHealth = 'unknown'
|
||||||
|
if (enabled && lastActivity) {
|
||||||
|
const elapsed = Date.now() - new Date(lastActivity.created_at.replace(' ', 'T') + '+08:00').getTime()
|
||||||
|
if (elapsed < 180_000) {
|
||||||
|
workerHealth = lastActivity.action === 'error' ? 'error' : 'healthy'
|
||||||
|
} else {
|
||||||
|
workerHealth = 'unresponsive'
|
||||||
|
}
|
||||||
|
} else if (!enabled) {
|
||||||
|
workerHealth = 'stopped'
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastError = db.prepare("SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 1").get() as { details: string; created_at: string } | undefined
|
||||||
|
|
||||||
|
// 今日处理详情:从 monitor_logs 获取今天的工单创建记录
|
||||||
|
const todayProcessedDetails = db.prepare(
|
||||||
|
"SELECT details, created_at FROM monitor_logs WHERE action = 'ticket_created' AND created_at >= ? ORDER BY id DESC LIMIT 20"
|
||||||
|
).all(todayStart) as { details: string; created_at: string }[]
|
||||||
|
|
||||||
|
// 进行中故障详情
|
||||||
|
const ongoingFaultsDetails = db.prepare(
|
||||||
|
"SELECT * FROM fault_records WHERE status = 'ongoing' ORDER BY fault_time DESC LIMIT 20"
|
||||||
|
).all() as Record<string, unknown>[]
|
||||||
|
|
||||||
|
// 最近错误列表
|
||||||
|
const recentErrors = db.prepare(
|
||||||
|
"SELECT details, created_at FROM monitor_logs WHERE action = 'error' ORDER BY id DESC LIMIT 10"
|
||||||
|
).all() as { details: string; created_at: string }[]
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
enabled,
|
||||||
|
status: enabled ? 'running' : 'stopped',
|
||||||
|
workerHealth,
|
||||||
|
lastCheck: lastTick?.created_at || null,
|
||||||
|
stats: {
|
||||||
|
totalProcessed,
|
||||||
|
todayProcessed,
|
||||||
|
errors: errorCount,
|
||||||
|
lastError: lastError?.details || null,
|
||||||
|
lastErrorTime: lastError?.created_at || null,
|
||||||
|
},
|
||||||
|
ongoingFaults: ongoingFaultsCount,
|
||||||
|
details: {
|
||||||
|
todayProcessed: todayProcessedDetails.map(d => {
|
||||||
|
try { return { ...JSON.parse(d.details), created_at: d.created_at } }
|
||||||
|
catch { return { details: d.details, created_at: d.created_at } }
|
||||||
|
}),
|
||||||
|
ongoingFaults: ongoingFaultsDetails,
|
||||||
|
recentErrors: recentErrors.map(d => {
|
||||||
|
try { return { ...JSON.parse(d.details), created_at: d.created_at } }
|
||||||
|
catch { return { details: d.details, created_at: d.created_at } }
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: e instanceof Error ? e.message : 'Internal error' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
// src/app/api/monitor/stop/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { setSetting } from '@/lib/monitor/settings-manager'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
setSetting('monitor.enabled', 'false', 'monitor')
|
||||||
|
writeAuditLog({ userId: user.id, action: 'disable', entityType: 'monitor', details: { message: '停用邮件监控' }, ipAddress: getClientIP(request) })
|
||||||
|
return NextResponse.json({ success: true, message: '监控已停用' })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
// src/app/api/monitor/test-mail/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||||
|
import { ImapFlow } from 'imapflow'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
const config = getMonitorConfig()
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: config.mail.imap_server, port: config.mail.imap_port, secure: true,
|
||||||
|
auth: { user: config.mail.address, pass: config.mail.password },
|
||||||
|
logger: false, connectionTimeout: 10_000,
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await client.connect()
|
||||||
|
const lock = await client.getMailboxLock('INBOX')
|
||||||
|
lock.release()
|
||||||
|
await client.logout()
|
||||||
|
return NextResponse.json({ success: true, message: 'IMAP 连接成功,INBOX 选择正常' })
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '连接失败' })
|
||||||
|
} finally {
|
||||||
|
try { await client.logout() } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
// src/app/api/monitor/test-wechat/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
const config = getMonitorConfig()
|
||||||
|
if (!config.wechat.webhook_url) return NextResponse.json({ success: false, error: 'Webhook URL 未配置' })
|
||||||
|
try {
|
||||||
|
const response = await fetch(config.wechat.webhook_url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ msgtype: 'text', text: { content: '✅ issue-ai 邮件监控测试消息' } }),
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
|
})
|
||||||
|
const result = await response.json() as { errcode?: number; errmsg?: string }
|
||||||
|
if (result.errcode === 0) {
|
||||||
|
writeAuditLog({ userId: user.id, action: 'test', entityType: 'monitor_wechat', details: { message: '测试微信推送成功' }, ipAddress: getClientIP(request) })
|
||||||
|
return NextResponse.json({ success: true, message: '测试消息发送成功' })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: false, error: result.errmsg || '发送失败' })
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ success: false, error: e instanceof Error ? e.message : '发送失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// src/app/api/monitor/trigger/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { setSetting } from '@/lib/monitor/settings-manager'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
|
||||||
|
let lastTriggerTime = 0
|
||||||
|
const TRIGGER_COOLDOWN = 30_000
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:write')) return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
if (Date.now() - lastTriggerTime < TRIGGER_COOLDOWN) return NextResponse.json({ error: '请等待 30 秒后再试' }, { status: 429 })
|
||||||
|
lastTriggerTime = Date.now()
|
||||||
|
// 写入触发标记,Worker 在下一个 tick 检测到后立即执行
|
||||||
|
setSetting('monitor.trigger_requested', 'true', 'monitor')
|
||||||
|
writeAuditLog({ userId: user.id, action: 'trigger', entityType: 'monitor', details: { message: '手动触发邮件检查' }, ipAddress: getClientIP(request) })
|
||||||
|
return NextResponse.json({ success: true, message: '已触发,Worker 将在 1-2 秒内执行' })
|
||||||
|
}
|
||||||
|
|
@ -143,7 +143,7 @@ export async function DELETE(_request: NextRequest, { params }: { params: Promis
|
||||||
if (!existing) return NextResponse.json({ error: '工单不存在' }, { status: 404 })
|
if (!existing) return NextResponse.json({ error: '工单不存在' }, { status: 404 })
|
||||||
|
|
||||||
// 获取工单快照用于审计日志
|
// 获取工单快照用于审计日志
|
||||||
const snapshot = db.prepare('SELECT id, ticket_no, device_ip, current_status FROM tickets WHERE id = ?').get(id) as Record<string, unknown> | undefined
|
const snapshot = db.prepare('SELECT id, device_ip, current_status FROM tickets WHERE id = ?').get(id) as Record<string, unknown> | undefined
|
||||||
|
|
||||||
// 非管理员不可删除已办工单,非创建人不可删除待办工单
|
// 非管理员不可删除已办工单,非创建人不可删除待办工单
|
||||||
const completedStatuses = ['resolved', 'closed']
|
const completedStatuses = ['resolved', 'closed']
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export async function GET(request: NextRequest) {
|
||||||
const result: Record<string, string[]> = {}
|
const result: Record<string, string[]> = {}
|
||||||
|
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'ticket_no', 'fault_subcategory']
|
const allowed = ['device_ip', 'device_name', 'fault_category', 'current_status', 'id', 'fault_subcategory']
|
||||||
if (!allowed.includes(field)) continue
|
if (!allowed.includes(field)) continue
|
||||||
const rows = db.prepare(`SELECT DISTINCT ${field} FROM tickets WHERE ${field} IS NOT NULL AND ${field} != '' ORDER BY ${field}`).all() as Record<string, string>[]
|
const rows = db.prepare(`SELECT DISTINCT ${field} FROM tickets WHERE ${field} IS NOT NULL AND ${field} != '' ORDER BY ${field}`).all() as Record<string, string>[]
|
||||||
result[field] = rows.map(r => r[field])
|
result[field] = rows.map(r => r[field])
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List } from 'lucide-react'
|
import { LayoutDashboard, FileText, Settings, Users, Shield, Key, Clock, CheckCircle, PlusSquare, Upload, List, Mail } from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null },
|
{ href: '/dashboard', label: '仪表盘', icon: LayoutDashboard, perm: null },
|
||||||
|
|
@ -18,6 +18,7 @@ const settingsItems = [
|
||||||
{ href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' },
|
{ href: '/settings/roles', label: '角色权限', icon: Shield, perm: 'roles:read' },
|
||||||
{ href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' },
|
{ href: '/settings/api-keys', label: 'API Key', icon: Key, perm: 'api-keys:read' },
|
||||||
{ href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' },
|
{ href: '/settings/audit-logs', label: '审计日志', icon: FileText, perm: 'audit-logs:read' },
|
||||||
|
{ href: '/settings/monitor', label: '邮件监控', icon: Mail, perm: 'monitor:read' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function hasAnyAdminPerm(permissions: string[]): boolean {
|
function hasAnyAdminPerm(permissions: string[]): boolean {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
type Variant = 'default' | 'success' | 'warning' | 'danger' | 'info'
|
||||||
|
// 兼容旧 API: blue→info, green→success, gray→default, red→danger, yellow→warning
|
||||||
|
type LegacyColor = 'blue' | 'green' | 'gray' | 'red' | 'yellow'
|
||||||
|
const legacyMap: Record<LegacyColor, Variant> = {
|
||||||
|
blue: 'info', green: 'success', gray: 'default', red: 'danger', yellow: 'warning',
|
||||||
|
}
|
||||||
|
interface BadgeProps { children: ReactNode; variant?: Variant; color?: LegacyColor }
|
||||||
|
const vs: Record<Variant, string> = {
|
||||||
|
default: 'bg-slate-100 dark:bg-slate-800 dark:text-slate-300 text-slate-700',
|
||||||
|
success: 'bg-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 text-emerald-700',
|
||||||
|
warning: 'bg-amber-100 dark:bg-amber-500/10 dark:text-amber-400 text-amber-700',
|
||||||
|
danger: 'bg-red-100 dark:bg-red-500/10 dark:text-red-400 text-red-700',
|
||||||
|
info: 'bg-blue-100 dark:bg-blue-500/10 dark:text-blue-400 text-blue-700',
|
||||||
|
}
|
||||||
|
export default function Badge({ children, variant = 'default', color }: BadgeProps) {
|
||||||
|
const v = color ? vs[legacyMap[color]] : vs[variant]
|
||||||
|
return <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${v}`}>{children}</span>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export interface Column {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
width?: string
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
render?: (record: any, index: number) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableProps {
|
||||||
|
columns: Column[]
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
data: any[]
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
rowKey: (record: any) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Table({ columns, data, rowKey }: TableProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300"
|
||||||
|
style={col.width ? { width: col.width } : undefined}
|
||||||
|
>
|
||||||
|
{col.title}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
{data.map((record, index) => (
|
||||||
|
<tr key={rowKey(record)} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} className="px-4 py-3">
|
||||||
|
{col.render ? col.render(record, index) : String(record[col.key] ?? '')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="px-4 py-12 text-center text-slate-500">暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,8 @@ export function Select({ label, options, className = '', ...props }: SelectProps
|
||||||
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<select className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props}>{options.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select></div>)
|
return (<div className="space-y-1">{label && <label className="block text-sm font-medium text-slate-700 dark:text-slate-300">{label}</label>}<select className={`w-full px-3 py-2 rounded-lg border bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-colors text-sm ${className}`} {...props}>{options.map((o) => (<option key={o.value} value={o.value}>{o.label}</option>))}</select></div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TableProps { headers: string[]; children: ReactNode }
|
interface TableHeadersProps { headers: string[]; children: ReactNode }
|
||||||
export function Table({ headers, children }: TableProps) {
|
export function Table({ headers, children }: TableHeadersProps) {
|
||||||
return (<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700"><table className="w-full text-sm"><thead className="bg-slate-50 dark:bg-slate-800"><tr>{headers.map((h) => (<th key={h} className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300">{h}</th>))}</tr></thead><tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody></table></div>)
|
return (<div className="overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700"><table className="w-full text-sm"><thead className="bg-slate-50 dark:bg-slate-800"><tr>{headers.map((h) => (<th key={h} className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300">{h}</th>))}</tr></thead><tbody className="divide-y divide-slate-200 dark:divide-slate-700">{children}</tbody></table></div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export interface Asset {
|
||||||
manufacturer: string
|
manufacturer: string
|
||||||
device_model: string
|
device_model: string
|
||||||
status: string
|
status: string
|
||||||
|
rack_position: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assetsFetch(path: string, options?: RequestInit) {
|
async function assetsFetch(path: string, options?: RequestInit) {
|
||||||
|
|
@ -106,3 +107,33 @@ export async function getActiveDevices(deviceType?: string): Promise<Asset[]> {
|
||||||
|
|
||||||
return allAssets
|
return allAssets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRackPosition(ip: string | null, sn: string | null): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// 优先通过 IP 查询
|
||||||
|
if (ip) {
|
||||||
|
const data = await assetsFetch(`/assets?search=${encodeURIComponent(ip)}&pageSize=10`)
|
||||||
|
if (data.data && Array.isArray(data.data)) {
|
||||||
|
for (const asset of data.data) {
|
||||||
|
if (asset.business_ip === ip || asset.hdm_ip === ip) {
|
||||||
|
return asset.rack_position || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 回退通过 SN 查询
|
||||||
|
if (sn) {
|
||||||
|
const data = await assetsFetch(`/assets?search=${encodeURIComponent(sn)}&pageSize=10`)
|
||||||
|
if (data.data && Array.isArray(data.data)) {
|
||||||
|
for (const asset of data.data) {
|
||||||
|
if (asset.serial_number === sn) {
|
||||||
|
return asset.rack_position || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,24 @@ export function writeAuditLog(opts: AuditLogOptions): void {
|
||||||
).get() as { value: string } | undefined
|
).get() as { value: string } | undefined
|
||||||
|
|
||||||
if (!lastCleanup || lastCleanup.value !== today) {
|
if (!lastCleanup || lastCleanup.value !== today) {
|
||||||
|
// 从 settings 读取保留天数,默认 180 天
|
||||||
|
const retentionRow = db.prepare(
|
||||||
|
"SELECT value FROM settings WHERE key = 'audit_retention_days'"
|
||||||
|
).get() as { value: string } | undefined
|
||||||
|
const retentionDays = Math.max(30, Math.min(365, parseInt(retentionRow?.value || '180', 10)))
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"DELETE FROM audit_logs WHERE created_at < datetime('now', '-180 days', '+8 hours')"
|
`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${retentionDays} days', '+8 hours')`
|
||||||
).run()
|
).run()
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)"
|
"INSERT OR REPLACE INTO settings (key, value) VALUES ('audit_cleanup_date', ?)"
|
||||||
).run(today)
|
).run(today)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 写入审计日志
|
// 写入审计日志(显式设置 created_at 为北京时间)
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address)
|
INSERT INTO audit_logs (user_id, api_key_id, action, entity_type, entity_id, details, ip_address, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+8 hours'))
|
||||||
`).run(
|
`).run(
|
||||||
userId ?? null,
|
userId ?? null,
|
||||||
apiKeyId ?? null,
|
apiKeyId ?? null,
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,77 @@ export function initDatabase(): void {
|
||||||
const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name)
|
const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name)
|
||||||
if (!ex) db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(r.name, r.display_name, r.permissions)
|
if (!ex) db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)').run(r.name, r.display_name, r.permissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 邮件监控系统表 ===
|
||||||
|
|
||||||
|
// settings 表迁移:新增列(表已存在,只有 key + value)
|
||||||
|
try { db.exec("ALTER TABLE settings ADD COLUMN category TEXT NOT NULL DEFAULT 'general'") } catch { /* 列已存在 */ }
|
||||||
|
try { db.exec("ALTER TABLE settings ADD COLUMN description TEXT") } catch { /* 列已存在 */ }
|
||||||
|
try { db.exec("ALTER TABLE settings ADD COLUMN updated_at TEXT") } catch { /* 列已存在 */ }
|
||||||
|
try { db.exec("ALTER TABLE settings ADD COLUMN updated_by INTEGER") } catch { /* 列已存在 */ }
|
||||||
|
|
||||||
|
// fault_records — 故障记录
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS fault_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_sn TEXT NOT NULL,
|
||||||
|
server_ip TEXT,
|
||||||
|
order_number TEXT,
|
||||||
|
fault_type TEXT,
|
||||||
|
fault_time TEXT NOT NULL,
|
||||||
|
recovery_time TEXT,
|
||||||
|
duration_seconds INTEGER,
|
||||||
|
status TEXT DEFAULT 'ongoing',
|
||||||
|
fault_detail TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now', '+8 hours')),
|
||||||
|
UNIQUE(server_sn, fault_time)
|
||||||
|
)`)
|
||||||
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_fault_records_sn ON fault_records(server_sn)") } catch { /* 索引已存在 */ }
|
||||||
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_fault_records_month ON fault_records(strftime('%Y-%m', fault_time))") } catch { /* 索引已存在 */ }
|
||||||
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_fault_records_status ON fault_records(status)") } catch { /* 索引已存在 */ }
|
||||||
|
|
||||||
|
// reminder_state — 提醒状态
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS reminder_state (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
server_sn TEXT NOT NULL,
|
||||||
|
server_ip TEXT,
|
||||||
|
order_number TEXT,
|
||||||
|
reminder_type TEXT NOT NULL,
|
||||||
|
deadline_time TEXT,
|
||||||
|
deadline_hours REAL,
|
||||||
|
fault_time TEXT,
|
||||||
|
tier_name TEXT,
|
||||||
|
notification_phase TEXT DEFAULT 'none',
|
||||||
|
notified_at TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
||||||
|
)`)
|
||||||
|
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_reminder_unique ON reminder_state(server_sn, reminder_type, COALESCE(tier_name, ''), fault_time)") } catch { /* 索引已存在 */ }
|
||||||
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_reminder_state_sn ON reminder_state(server_sn)") } catch { /* 索引已存在 */ }
|
||||||
|
|
||||||
|
// processed_emails — 邮件去重
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS processed_emails (
|
||||||
|
msg_id TEXT PRIMARY KEY,
|
||||||
|
subject TEXT,
|
||||||
|
processed_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
||||||
|
)`)
|
||||||
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_processed_emails_date ON processed_emails(processed_at)") } catch { /* 索引已存在 */ }
|
||||||
|
|
||||||
|
// pending_messages — 静默期积压队列
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS pending_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_type TEXT NOT NULL,
|
||||||
|
server_sn TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
||||||
|
)`)
|
||||||
|
|
||||||
|
// monitor_logs — 监控日志
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS monitor_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email_msg_id TEXT,
|
||||||
|
email_subject TEXT,
|
||||||
|
action TEXT,
|
||||||
|
details TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
||||||
|
)`)
|
||||||
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_monitor_logs_msg ON monitor_logs(email_msg_id)") } catch { /* 索引已存在 */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
// src/lib/monitor/availability-engine.ts
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import type { TierDeadline } from './types'
|
||||||
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
|
// Python calendar.monthrange 的 Node.js 实现
|
||||||
|
function monthrange(year: number, month: number): [number, number] {
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate()
|
||||||
|
const firstDayOfWeek = new Date(year, month - 1, 1).getDay()
|
||||||
|
return [firstDayOfWeek, daysInMonth]
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIERS = {
|
||||||
|
'第一档': { max_rate: 0.01, description: '99%(含)以上(不扣费)' },
|
||||||
|
'第二档': { max_rate: 0.03, description: '99%-97%(含)(扣10%)' },
|
||||||
|
'第三档': { max_rate: 0.05, description: '97%-95%(含)(扣25%)' },
|
||||||
|
'第四档': { max_rate: 0.10, description: '95%-90%(含)(扣50%)' },
|
||||||
|
'第五档': { max_rate: 0.10, description: '90%(不含)以下(扣100%)' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AvailabilityEngine {
|
||||||
|
private db = getDb()
|
||||||
|
|
||||||
|
getDaysInMonth(monthKey: string): number {
|
||||||
|
const [y, m] = monthKey.split('-').map(Number)
|
||||||
|
return monthrange(y, m)[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
getMonthlyFaultDuration(monthKey: string, serverSn: string): number {
|
||||||
|
const row = this.db.prepare(
|
||||||
|
"SELECT COALESCE(SUM(duration_seconds), 0) as total FROM fault_records WHERE server_sn = ? AND status = 'resolved' AND strftime('%Y-%m', fault_time) = ?"
|
||||||
|
).get(serverSn, monthKey) as { total: number } | undefined
|
||||||
|
return row?.total ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateTierDeadlines(monthKey: string, serverSn: string, faultTime: string): Record<string, TierDeadline> {
|
||||||
|
const daysInMonth = this.getDaysInMonth(monthKey)
|
||||||
|
const monthTotalSeconds = daysInMonth * 24 * 3600
|
||||||
|
const totalDuration = this.getMonthlyFaultDuration(monthKey, serverSn)
|
||||||
|
const faultDate = new Date(faultTime.replace(' ', 'T') + '+08:00')
|
||||||
|
const deadlines: Record<string, TierDeadline> = {}
|
||||||
|
|
||||||
|
for (const [tierName, tier] of Object.entries(TIERS)) {
|
||||||
|
const maxDuration = monthTotalSeconds * tier.max_rate
|
||||||
|
const remaining = maxDuration - totalDuration
|
||||||
|
if (remaining > 0) {
|
||||||
|
const deadline = new Date(faultDate.getTime() + remaining * 1000)
|
||||||
|
deadlines[tierName] = {
|
||||||
|
deadline: formatBeijingTime(deadline),
|
||||||
|
hours: Math.round(remaining / 3600 * 100) / 100,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deadlines[tierName] = { deadline: '已超出', hours: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deadlines
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateOemDiagDeadline(monthKey: string, faultTime: string): string | null {
|
||||||
|
const daysInMonth = this.getDaysInMonth(monthKey)
|
||||||
|
const monthTotalSeconds = daysInMonth * 24 * 3600
|
||||||
|
const firstTierDuration = monthTotalSeconds * 0.01
|
||||||
|
const faultDate = new Date(faultTime.replace(' ', 'T') + '+08:00')
|
||||||
|
const deadline = new Date(faultDate.getTime() + firstTierDuration * 1000)
|
||||||
|
return formatBeijingTime(deadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
// src/lib/monitor/mail-monitor.ts
|
||||||
|
import { ImapFlow } from 'imapflow'
|
||||||
|
import { simpleParser } from 'mailparser'
|
||||||
|
import * as cheerio from 'cheerio'
|
||||||
|
import type { EmailInfo, FaultInfo, MonitorConfig } from './types'
|
||||||
|
|
||||||
|
export class MailMonitor {
|
||||||
|
private client: ImapFlow | null = null
|
||||||
|
private retryCount = 0
|
||||||
|
private readonly RETRY_DELAYS = [5_000, 15_000, 45_000]
|
||||||
|
|
||||||
|
async connect(config: MonitorConfig['mail']): Promise<void> {
|
||||||
|
this.client = new ImapFlow({
|
||||||
|
host: config.imap_server,
|
||||||
|
port: config.imap_port,
|
||||||
|
secure: true,
|
||||||
|
auth: { user: config.address, pass: config.password },
|
||||||
|
logger: false,
|
||||||
|
connectionTimeout: 15_000,
|
||||||
|
})
|
||||||
|
await this.client.connect()
|
||||||
|
this.retryCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.client) {
|
||||||
|
try { await this.client.logout() } catch {}
|
||||||
|
this.client = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.client !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnect(config: MonitorConfig['mail']): Promise<void> {
|
||||||
|
await this.disconnect()
|
||||||
|
const delay = this.RETRY_DELAYS[Math.min(this.retryCount, this.RETRY_DELAYS.length - 1)]
|
||||||
|
this.retryCount++
|
||||||
|
await new Promise(r => setTimeout(r, delay))
|
||||||
|
await this.connect(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
async keepAlive(): Promise<boolean> {
|
||||||
|
if (!this.client) return false
|
||||||
|
try {
|
||||||
|
await this.client.noop()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
this.client = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUnread(config: MonitorConfig): Promise<EmailInfo[]> {
|
||||||
|
if (!this.client) return []
|
||||||
|
const lock = await this.client.getMailboxLock('INBOX')
|
||||||
|
try {
|
||||||
|
const today = new Date()
|
||||||
|
const since = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||||
|
// 不使用 unseen 条件(IMAP 标记不可靠),依赖 processed_emails 表去重
|
||||||
|
const uids = await this.client.search({ since }, { uid: true })
|
||||||
|
if (!uids || uids.length === 0) return []
|
||||||
|
|
||||||
|
const emails: EmailInfo[] = []
|
||||||
|
for (const uid of uids) {
|
||||||
|
try {
|
||||||
|
const msg = await this.client.fetchOne(uid, { source: true, uid: true }, { uid: true })
|
||||||
|
if (!msg || !('source' in msg) || !(msg as any).source) continue
|
||||||
|
const parsed = await simpleParser((msg as any).source as Buffer)
|
||||||
|
const emailInfo: EmailInfo = {
|
||||||
|
msg_id: parsed.messageId || String(uid),
|
||||||
|
subject: parsed.subject || '',
|
||||||
|
sender: parsed.from?.text || '',
|
||||||
|
date: parsed.date?.toISOString() || '',
|
||||||
|
html: parsed.html || null,
|
||||||
|
}
|
||||||
|
emails.push(emailInfo)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return emails
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterEmail(email: EmailInfo, config: MonitorConfig['filter']): { pass: boolean; type: 'oem_diag' | 'oem_repair' | null } {
|
||||||
|
// 主题关键词过滤
|
||||||
|
const subjectMatch = config.subject_keywords.some(kw => email.subject.includes(kw))
|
||||||
|
if (!subjectMatch) return { pass: false, type: null }
|
||||||
|
// 发件人过滤
|
||||||
|
if (!email.sender.includes(config.sender_email)) return { pass: false, type: null }
|
||||||
|
// OEM 类型识别
|
||||||
|
const repair = config.oem_repair_keywords
|
||||||
|
const diag = config.oem_diag_keywords
|
||||||
|
const isRepair = email.subject.includes(repair.base_keyword) && email.subject.includes(repair.type_keyword) && !email.subject.includes(repair.exclude_keyword)
|
||||||
|
const isDiag = email.subject.includes(diag.base_keyword) && email.subject.includes(diag.type_keyword) && !email.subject.includes(diag.exclude_keyword)
|
||||||
|
if (isRepair) return { pass: true, type: 'oem_repair' }
|
||||||
|
if (isDiag) return { pass: true, type: 'oem_diag' }
|
||||||
|
return { pass: false, type: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
extractTable(html: string): string[][] | null {
|
||||||
|
const $ = cheerio.load(html)
|
||||||
|
const tables = $('table')
|
||||||
|
if (tables.length === 0) return null
|
||||||
|
let targetTable: any = null
|
||||||
|
tables.each((_, table) => {
|
||||||
|
if ($(table).text().includes('故障信息')) {
|
||||||
|
targetTable = table
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!targetTable) targetTable = tables[0]!
|
||||||
|
const rows: string[][] = []
|
||||||
|
$(targetTable).find('tr').each((_, row) => {
|
||||||
|
const cells: string[] = []
|
||||||
|
$(row).find('th, td').each((_, cell) => {
|
||||||
|
cells.push($(cell).text().trim())
|
||||||
|
})
|
||||||
|
if (cells.length > 0) rows.push(cells)
|
||||||
|
})
|
||||||
|
return rows.length > 0 ? rows : null
|
||||||
|
}
|
||||||
|
|
||||||
|
extractFaultInfo(html: string, tableData: string[][] | null): FaultInfo {
|
||||||
|
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ')
|
||||||
|
const patterns = {
|
||||||
|
fault_time: [/故障(?:发生)?时间[::\s]*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/, /(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/],
|
||||||
|
server_sn: [/服务器SN[::\s]*([A-Za-z0-9]+)/, /SN[::\s]*([A-Za-z0-9]+)/, /([A-Za-z0-9]{15,25})/],
|
||||||
|
server_ip: [/服务器IP[::\s]*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/, /IP[::\s]*(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/, /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/],
|
||||||
|
}
|
||||||
|
const result: FaultInfo = { fault_time: null, server_sn: null, server_ip: null, order_number: null, fault_detail: null }
|
||||||
|
for (const [key, regexes] of Object.entries(patterns)) {
|
||||||
|
for (const regex of regexes) {
|
||||||
|
const match = text.match(regex)
|
||||||
|
if (match) { result[key as keyof FaultInfo] = match[1] as never; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 从主题提取工单号
|
||||||
|
const orderMatch = html.match(/【服务器故障单】(\d+),/)
|
||||||
|
if (orderMatch) result.order_number = orderMatch[1]
|
||||||
|
// 从表格提取故障详情
|
||||||
|
if (tableData) {
|
||||||
|
for (const row of tableData) {
|
||||||
|
const idx = row.findIndex(cell => cell.includes('故障信息'))
|
||||||
|
if (idx >= 0 && idx + 1 < row.length) {
|
||||||
|
result.fault_detail = row[idx + 1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
extractOrderNumber(subject: string): string | null {
|
||||||
|
const match = subject.match(/【服务器故障单】(\d+),/)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
// src/lib/monitor/reminder-engine.ts
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import type { MonitorConfig, ReminderState } from './types'
|
||||||
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string) => console.log(`[Worker] ${formatBeijingTime()} INFO ${msg}`),
|
||||||
|
error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReminderEngine {
|
||||||
|
private db = getDb()
|
||||||
|
|
||||||
|
isInSilentPeriod(config: MonitorConfig): boolean {
|
||||||
|
const hour = new Date().getHours()
|
||||||
|
return hour >= config.monitor.silent_start_hour && hour < config.monitor.silent_end_hour
|
||||||
|
}
|
||||||
|
|
||||||
|
isTimeToFlush(): boolean {
|
||||||
|
const now = new Date()
|
||||||
|
if (now.getHours() !== 7) return false
|
||||||
|
// 检查今天是否已 flush 过(防止重复执行)
|
||||||
|
const flushed = this.db.prepare("SELECT id FROM monitor_logs WHERE action = 'flush_complete' AND date(created_at, '+8 hours') = date('now', '+8 hours')").get()
|
||||||
|
return !flushed
|
||||||
|
}
|
||||||
|
|
||||||
|
markFlushed(): void {
|
||||||
|
this.db.prepare("INSERT INTO monitor_logs (action, details) VALUES ('flush_complete', ?)").run(formatBeijingTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
createReminder(reminder: Omit<ReminderState, 'id' | 'created_at'>): void {
|
||||||
|
try {
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO reminder_state (server_sn, server_ip, order_number, reminder_type, deadline_time, deadline_hours, fault_time, tier_name, notification_phase)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(reminder.server_sn, reminder.server_ip, reminder.order_number, reminder.reminder_type, reminder.deadline_time, reminder.deadline_hours, reminder.fault_time, reminder.tier_name, reminder.notification_phase || 'none')
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Create reminder failed: ${e instanceof Error ? e.message : e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addPendingMessage(messageType: 'oem_diag' | 'oem_repair', serverSn: string, payload: object): void {
|
||||||
|
// 去重:删除同 server_sn + message_type + tier_name 的旧记录
|
||||||
|
const tierName = (payload as Record<string, unknown>).tier_name
|
||||||
|
if (tierName) {
|
||||||
|
this.db.prepare("DELETE FROM pending_messages WHERE server_sn = ? AND message_type = ? AND json_extract(payload, '$.tier_name') = ?").run(serverSn, messageType, tierName)
|
||||||
|
} else {
|
||||||
|
this.db.prepare("DELETE FROM pending_messages WHERE server_sn = ? AND message_type = ?").run(serverSn, messageType)
|
||||||
|
}
|
||||||
|
this.db.prepare("INSERT INTO pending_messages (message_type, server_sn, payload) VALUES (?, ?, ?)").run(messageType, serverSn, JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkOemDiagReminders(pushText: (text: string) => Promise<boolean>, pushDelay: number, getRack?: (ip: string | null, sn: string | null) => Promise<string | null>, config?: MonitorConfig): Promise<void> {
|
||||||
|
const reminders = this.db.prepare("SELECT * FROM reminder_state WHERE reminder_type = 'oem_diag' AND notification_phase != 'completed'").all() as ReminderState[]
|
||||||
|
const now = new Date()
|
||||||
|
const inSilent = config ? this.isInSilentPeriod(config) : this.isInSilentPeriod({ monitor: { silent_start_hour: 0, silent_end_hour: 7 } } as MonitorConfig)
|
||||||
|
|
||||||
|
for (const r of reminders) {
|
||||||
|
if (!r.deadline_time || !r.fault_time) continue
|
||||||
|
const deadline = new Date(r.deadline_time.replace(' ', 'T') + '+08:00')
|
||||||
|
const faultDate = new Date(r.fault_time.replace(' ', 'T') + '+08:00')
|
||||||
|
const timeToDeadline = (deadline.getTime() - now.getTime()) / 1000
|
||||||
|
const timeSinceFault = (now.getTime() - faultDate.getTime()) / 1000
|
||||||
|
|
||||||
|
// 超时提醒(只发一次)
|
||||||
|
if (timeToDeadline <= 0 && r.notification_phase !== 'overdue') {
|
||||||
|
const rack = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
||||||
|
let text = '⚠️ OEM诊断工单已超时\n\n'
|
||||||
|
if (r.order_number) text += `工单号:${r.order_number}\n`
|
||||||
|
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
||||||
|
if (rack) text += `机架位置:${rack}\n`
|
||||||
|
text += `到期时间:${r.deadline_time}\n\n服务器尚未录入恢复时间,请尽快处理!`
|
||||||
|
if (await pushText(text)) {
|
||||||
|
this.db.prepare("UPDATE reminder_state SET notification_phase = 'overdue', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (timeToDeadline <= 0) continue
|
||||||
|
|
||||||
|
// 故障未满 1 小时不提醒
|
||||||
|
if (timeSinceFault < 3600) continue
|
||||||
|
|
||||||
|
// 剩余 30 分钟特别提醒
|
||||||
|
if (timeToDeadline <= 1800) {
|
||||||
|
if (inSilent) {
|
||||||
|
this.addPendingMessage('oem_diag', r.server_sn, { type: 'diag_30min', server_ip: r.server_ip, server_sn: r.server_sn, fault_time: r.fault_time, deadline: r.deadline_time, order_number: r.order_number })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rack30 = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
||||||
|
let text = '⚠️ OEM诊断工单即将到期(剩余30分钟)\n\n'
|
||||||
|
if (r.order_number) text += `工单号:${r.order_number}\n`
|
||||||
|
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
||||||
|
if (rack30) text += `机架位置:${rack30}\n`
|
||||||
|
text += `到期时间:${r.deadline_time}\n\n服务器尚未录入恢复时间,请尽快处理!`
|
||||||
|
if (await pushText(text)) {
|
||||||
|
this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每小时提醒
|
||||||
|
if (r.notification_phase === 'none' || (r.notified_at && (now.getTime() - new Date(r.notified_at.replace(' ', 'T') + '+08:00').getTime()) >= 3600000)) {
|
||||||
|
if (inSilent) {
|
||||||
|
this.addPendingMessage('oem_diag', r.server_sn, { type: 'diag_hourly', server_ip: r.server_ip, server_sn: r.server_sn, fault_time: r.fault_time, deadline: r.deadline_time, order_number: r.order_number })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rackH = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
||||||
|
const remainingHours = Math.floor(timeToDeadline / 3600)
|
||||||
|
let text = `⏰ OEM诊断工单提醒(剩余${remainingHours}小时)\n\n`
|
||||||
|
if (r.order_number) text += `工单号:${r.order_number}\n`
|
||||||
|
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
||||||
|
if (rackH) text += `机架位置:${rackH}\n`
|
||||||
|
text += `到期时间:${r.deadline_time}\n服务器尚未录入恢复时间,请及时处理!`
|
||||||
|
if (await pushText(text)) {
|
||||||
|
this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id)
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pushDelay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async flushPendingMessages(pushText: (text: string) => Promise<boolean>): Promise<void> {
|
||||||
|
const pending = this.db.prepare('SELECT * FROM pending_messages').all() as { id: number; message_type: string; server_sn: string; payload: string }[]
|
||||||
|
for (const p of pending) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(p.payload)
|
||||||
|
if (payload.type === 'repair_overdue' || payload.type === 'repair_all_overdue' || payload.type === 'diag_overdue') {
|
||||||
|
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 重新计算剩余时间
|
||||||
|
if (payload.deadline) {
|
||||||
|
const deadline = new Date(payload.deadline.replace(' ', 'T') + '+08:00')
|
||||||
|
const remaining = (deadline.getTime() - Date.now()) / 1000
|
||||||
|
if (remaining <= 0) {
|
||||||
|
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 构建消息
|
||||||
|
let text = ''
|
||||||
|
if (p.message_type === 'oem_diag') {
|
||||||
|
text = `⏰ OEM诊断工单提醒\n\n`
|
||||||
|
if (payload.order_number) text += `工单号:${payload.order_number}\n`
|
||||||
|
text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n到期时间:${payload.deadline}\n服务器尚未录入恢复时间,请及时处理!`
|
||||||
|
} else if (p.message_type === 'oem_repair' && payload.type === 'repair_before') {
|
||||||
|
text = `⏰ OEM维修工单提醒\n\n`
|
||||||
|
if (payload.order_number) text += `工单号:${payload.order_number}\n`
|
||||||
|
text += `服务器IP:${payload.server_ip || '未知'}\n服务器SN:${payload.server_sn}\n到期时间:${payload.deadline}\n服务器尚未录入恢复时间,请及时处理!`
|
||||||
|
}
|
||||||
|
if (text && await pushText(text)) {
|
||||||
|
this.db.prepare('DELETE FROM pending_messages WHERE id = ?').run(p.id)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Flush pending failed: ${e instanceof Error ? e.message : e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkOemRepairReminders(pushText: (text: string) => Promise<boolean>, pushDelay: number, getRack?: (ip: string | null, sn: string | null) => Promise<string | null>, config?: MonitorConfig): Promise<void> {
|
||||||
|
const reminders = this.db.prepare("SELECT * FROM reminder_state WHERE reminder_type = 'oem_repair' AND notification_phase != 'completed'").all() as ReminderState[]
|
||||||
|
const now = new Date()
|
||||||
|
const inSilent = config ? this.isInSilentPeriod(config) : this.isInSilentPeriod({ monitor: { silent_start_hour: 0, silent_end_hour: 7 } } as MonitorConfig)
|
||||||
|
|
||||||
|
for (const r of reminders) {
|
||||||
|
if (!r.deadline_time || !r.tier_name) continue
|
||||||
|
const deadline = new Date(r.deadline_time.replace(' ', 'T') + '+08:00')
|
||||||
|
const timeToDeadline = (deadline.getTime() - now.getTime()) / 1000
|
||||||
|
|
||||||
|
if (timeToDeadline <= 0 && r.notification_phase !== 'overdue') {
|
||||||
|
const rackRO = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
||||||
|
let text = `⚠️ OEM维修工单已触发${r.tier_name}超时\n\n`
|
||||||
|
if (r.order_number) text += `工单号:${r.order_number}\n`
|
||||||
|
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
||||||
|
if (rackRO) text += `机架位置:${rackRO}\n`
|
||||||
|
text += `${r.tier_name}到期时间:${r.deadline_time}\n\n服务器尚未录入恢复时间,请尽快处理!`
|
||||||
|
if (await pushText(text)) {
|
||||||
|
this.db.prepare("UPDATE reminder_state SET notification_phase = 'overdue', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id)
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pushDelay))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (timeToDeadline <= 0) continue
|
||||||
|
|
||||||
|
if (timeToDeadline <= 3600 && r.notification_phase === 'none') {
|
||||||
|
if (inSilent) {
|
||||||
|
this.addPendingMessage('oem_repair', r.server_sn, { type: 'repair_before', tier_name: r.tier_name, server_ip: r.server_ip, server_sn: r.server_sn, fault_time: r.fault_time, deadline: r.deadline_time, order_number: r.order_number })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rackRB = getRack ? await getRack(r.server_ip, r.server_sn) : null
|
||||||
|
let text = `⏰ OEM维修工单提醒(距离${r.tier_name}超时还剩1小时)\n\n`
|
||||||
|
if (r.order_number) text += `工单号:${r.order_number}\n`
|
||||||
|
text += `服务器IP:${r.server_ip || '未知'}\n服务器SN:${r.server_sn}\n`
|
||||||
|
if (rackRB) text += `机架位置:${rackRB}\n`
|
||||||
|
text += `${r.tier_name}到期时间:${r.deadline_time}\n服务器尚未录入恢复时间,请及时处理!`
|
||||||
|
if (await pushText(text)) {
|
||||||
|
this.db.prepare("UPDATE reminder_state SET notification_phase = 'before', notified_at = datetime('now', '+8 hours') WHERE id = ?").run(r.id)
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pushDelay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupForTicket(orderNumber: string): void {
|
||||||
|
this.db.prepare("DELETE FROM reminder_state WHERE order_number = ?").run(orderNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
// src/lib/monitor/settings-manager.ts
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import type { MonitorConfig } from './types'
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-cbc'
|
||||||
|
|
||||||
|
function getEncryptionKey(): string {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare("SELECT value FROM settings WHERE key = 'monitor.encryption_key'").get() as { value: string } | undefined
|
||||||
|
if (row) return row.value
|
||||||
|
// 首次启动:生成密钥
|
||||||
|
const newKey = crypto.randomBytes(32).toString('hex')
|
||||||
|
db.prepare("INSERT OR IGNORE INTO settings (key, value, category) VALUES ('monitor.encryption_key', ?, 'security')").run(newKey)
|
||||||
|
return newKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptValue(plaintext: string): string {
|
||||||
|
if (!plaintext) return ''
|
||||||
|
const key = Buffer.from(getEncryptionKey(), 'hex')
|
||||||
|
const iv = crypto.randomBytes(16)
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
|
||||||
|
let encrypted = cipher.update(plaintext, 'utf8', 'hex')
|
||||||
|
encrypted += cipher.final('hex')
|
||||||
|
return iv.toString('hex') + ':' + encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptValue(ciphertext: string): string {
|
||||||
|
if (!ciphertext) return ''
|
||||||
|
try {
|
||||||
|
const key = Buffer.from(getEncryptionKey(), 'hex')
|
||||||
|
const [ivHex, encrypted] = ciphertext.split(':')
|
||||||
|
const iv = Buffer.from(ivHex, 'hex')
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||||
|
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||||
|
decrypted += decipher.final('utf8')
|
||||||
|
return decrypted
|
||||||
|
} catch {
|
||||||
|
console.error('[Settings] 密码解密失败,可能密钥不匹配')
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSetting(key: string): string | null {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined
|
||||||
|
return row?.value ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key: string, value: string, category = 'general'): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO settings (key, value, category, updated_at) VALUES (?, ?, ?, datetime('now', '+8 hours')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
|
||||||
|
).run(key, value, category)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: MonitorConfig = {
|
||||||
|
monitor: { enabled: false, interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
|
||||||
|
mail: { address: 'gxp@qx002575.com', imap_server: 'imaphz.qiye.163.com', imap_port: 993, smtp_server: 'smtphz.qiye.163.com', smtp_port: 465, password: '' },
|
||||||
|
filter: {
|
||||||
|
subject_keywords: ['服务器故障单'],
|
||||||
|
sender_email: 'tencent_dcops@tencent.com',
|
||||||
|
oem_repair_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM维修', exclude_keyword: 'OEM诊断' },
|
||||||
|
oem_diag_keywords: { base_keyword: '服务器故障单', type_keyword: 'OEM诊断', exclude_keyword: 'OEM维修' },
|
||||||
|
},
|
||||||
|
wechat: { webhook_url: '' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonitorConfig(): MonitorConfig {
|
||||||
|
const config = JSON.parse(JSON.stringify(DEFAULT_CONFIG))
|
||||||
|
const mappings: Record<string, (v: string) => void> = {
|
||||||
|
'monitor.enabled': (v) => { config.monitor.enabled = v === 'true' },
|
||||||
|
'monitor.interval_seconds': (v) => { config.monitor.interval_seconds = parseInt(v) || 60 },
|
||||||
|
'monitor.push_delay_ms': (v) => { config.monitor.push_delay_ms = parseInt(v) || 2000 },
|
||||||
|
'monitor.silent_start_hour': (v) => { config.monitor.silent_start_hour = parseInt(v) || 0 },
|
||||||
|
'monitor.silent_end_hour': (v) => { config.monitor.silent_end_hour = parseInt(v) || 7 },
|
||||||
|
'mail.address': (v) => { config.mail.address = v },
|
||||||
|
'mail.imap_server': (v) => { config.mail.imap_server = v },
|
||||||
|
'mail.imap_port': (v) => { config.mail.imap_port = parseInt(v) || 993 },
|
||||||
|
'mail.smtp_server': (v) => { config.mail.smtp_server = v },
|
||||||
|
'mail.smtp_port': (v) => { config.mail.smtp_port = parseInt(v) || 465 },
|
||||||
|
'mail.password': (v) => { config.mail.password = v ? decryptValue(v) : '' },
|
||||||
|
'filter.subject_keywords': (v) => { try { config.filter.subject_keywords = JSON.parse(v) } catch {} },
|
||||||
|
'filter.sender_email': (v) => { config.filter.sender_email = v },
|
||||||
|
'filter.oem_repair_keywords': (v) => { try { config.filter.oem_repair_keywords = JSON.parse(v) } catch {} },
|
||||||
|
'filter.oem_diag_keywords': (v) => { try { config.filter.oem_diag_keywords = JSON.parse(v) } catch {} },
|
||||||
|
'wechat.webhook_url': (v) => { config.wechat.webhook_url = v },
|
||||||
|
}
|
||||||
|
const db = getDb()
|
||||||
|
const rows = db.prepare("SELECT key, value FROM settings WHERE key LIKE 'monitor.%' OR key LIKE 'mail.%' OR key LIKE 'filter.%' OR key LIKE 'wechat.%'").all() as { key: string; value: string }[]
|
||||||
|
for (const row of rows) {
|
||||||
|
mappings[row.key]?.(row.value)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMonitorConfig(updates: Record<string, unknown>): void {
|
||||||
|
const encryptedFields = new Set(['mail.password'])
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (key === 'monitor.encryption_key') continue // 不允许修改密钥
|
||||||
|
let stringValue: string
|
||||||
|
if (typeof value === 'boolean') stringValue = String(value)
|
||||||
|
else if (typeof value === 'number') stringValue = String(value)
|
||||||
|
else if (typeof value === 'object') stringValue = JSON.stringify(value)
|
||||||
|
else stringValue = String(value ?? '')
|
||||||
|
if (encryptedFields.has(key) && stringValue && !stringValue.match(/^[0-9a-f]{32}:/)) {
|
||||||
|
stringValue = encryptValue(stringValue)
|
||||||
|
}
|
||||||
|
const category = key.split('.')[0]
|
||||||
|
setSetting(key, stringValue, category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMaskedConfig(): MonitorConfig {
|
||||||
|
const config = getMonitorConfig()
|
||||||
|
if (config.mail.password) config.mail.password = '••••••••'
|
||||||
|
if (config.wechat.webhook_url) {
|
||||||
|
const keyMatch = config.wechat.webhook_url.match(/key=([0-9a-f-]+)/)
|
||||||
|
if (keyMatch) config.wechat.webhook_url = config.wechat.webhook_url.replace(keyMatch[1], '••••••••')
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
// src/lib/monitor/ticket-processor.ts
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { getRackPosition } from '@/lib/assets-client'
|
||||||
|
import type { TicketCreateInput, FaultInfo, FaultRecord, MonitorConfig } from './types'
|
||||||
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string) => console.log(`[Worker] ${formatBeijingTime()} INFO ${msg}`),
|
||||||
|
error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TicketProcessor {
|
||||||
|
private db = getDb()
|
||||||
|
|
||||||
|
createTicket(input: TicketCreateInput): boolean {
|
||||||
|
try {
|
||||||
|
const existing = this.db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(input.ticket_no))
|
||||||
|
if (existing) return false
|
||||||
|
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO tickets (id, device_ip, device_sn, device_name, content, assign_time,
|
||||||
|
ticket_type, fault_category, fault_subcategory, responsibility, current_status, counted_in_sla)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
parseInt(input.ticket_no),
|
||||||
|
input.device_ip, input.device_sn, input.device_name, input.content,
|
||||||
|
input.assign_time, input.ticket_type, input.fault_category,
|
||||||
|
input.fault_subcategory, input.responsibility, input.current_status,
|
||||||
|
input.counted_in_sla,
|
||||||
|
)
|
||||||
|
logger.info(`Ticket created: ${input.ticket_no}`)
|
||||||
|
// 注意:Worker 创建的工单不记录 ticket_steps(与外部 API 行为一致)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Ticket creation failed: ${e instanceof Error ? e.message : e}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRackPosition(ip: string | null, sn: string | null): Promise<string | null> {
|
||||||
|
return getRackPosition(ip, sn)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordFault(fault: FaultRecord): void {
|
||||||
|
try {
|
||||||
|
const existing = this.db.prepare('SELECT id FROM fault_records WHERE server_sn = ? AND fault_time = ?').get(fault.server_sn, fault.fault_time)
|
||||||
|
if (existing) return
|
||||||
|
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO fault_records (server_sn, server_ip, order_number, fault_type, fault_time, fault_detail, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'ongoing')
|
||||||
|
`).run(fault.server_sn, fault.server_ip, fault.order_number, fault.fault_type, fault.fault_time, fault.fault_detail)
|
||||||
|
logger.info(`Fault recorded: ${fault.server_sn} - ${fault.fault_time}`)
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Fault recording failed: ${e instanceof Error ? e.message : e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createTicketFromEmail(
|
||||||
|
faultInfo: FaultInfo,
|
||||||
|
type: 'oem_diag' | 'oem_repair',
|
||||||
|
config: MonitorConfig,
|
||||||
|
content: string,
|
||||||
|
): string | null {
|
||||||
|
if (!faultInfo.order_number || !faultInfo.fault_time) return null
|
||||||
|
|
||||||
|
const ticketType = type === 'oem_diag' ? 'OEM诊断' : 'OEM维修'
|
||||||
|
const input: TicketCreateInput = {
|
||||||
|
ticket_no: faultInfo.order_number,
|
||||||
|
device_ip: faultInfo.server_ip,
|
||||||
|
device_sn: faultInfo.server_sn,
|
||||||
|
device_name: null,
|
||||||
|
content,
|
||||||
|
assign_time: faultInfo.fault_time,
|
||||||
|
ticket_type: ticketType,
|
||||||
|
fault_category: ticketType,
|
||||||
|
fault_subcategory: null,
|
||||||
|
responsibility: null,
|
||||||
|
current_status: 'open',
|
||||||
|
counted_in_sla: 1,
|
||||||
|
}
|
||||||
|
const created = this.createTicket(input)
|
||||||
|
return created ? faultInfo.order_number : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
// src/lib/monitor/types.ts
|
||||||
|
|
||||||
|
export interface FaultRecord {
|
||||||
|
id?: number
|
||||||
|
server_sn: string
|
||||||
|
server_ip: string | null
|
||||||
|
order_number: string | null
|
||||||
|
fault_type: 'oem_diag' | 'oem_repair' | null
|
||||||
|
fault_time: string
|
||||||
|
recovery_time: string | null
|
||||||
|
duration_seconds: number | null
|
||||||
|
status: 'ongoing' | 'resolved'
|
||||||
|
fault_detail: string | null
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReminderState {
|
||||||
|
id?: number
|
||||||
|
server_sn: string
|
||||||
|
server_ip: string | null
|
||||||
|
order_number: string | null
|
||||||
|
reminder_type: 'oem_diag' | 'oem_repair'
|
||||||
|
deadline_time: string | null
|
||||||
|
deadline_hours: number | null
|
||||||
|
fault_time: string | null
|
||||||
|
tier_name: string | null
|
||||||
|
notification_phase: 'none' | 'before' | 'overdue' | 'completed'
|
||||||
|
notified_at: string | null
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedEmail {
|
||||||
|
msg_id: string
|
||||||
|
subject: string | null
|
||||||
|
processed_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingMessage {
|
||||||
|
id?: number
|
||||||
|
message_type: 'oem_diag' | 'oem_repair'
|
||||||
|
server_sn: string
|
||||||
|
payload: string // JSON
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorLog {
|
||||||
|
id?: number
|
||||||
|
email_msg_id: string | null
|
||||||
|
email_subject: string | null
|
||||||
|
action: string
|
||||||
|
details: string | null
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorConfig {
|
||||||
|
monitor: {
|
||||||
|
enabled: boolean
|
||||||
|
interval_seconds: number
|
||||||
|
push_delay_ms: number
|
||||||
|
silent_start_hour: number
|
||||||
|
silent_end_hour: number
|
||||||
|
}
|
||||||
|
mail: {
|
||||||
|
address: string
|
||||||
|
imap_server: string
|
||||||
|
imap_port: number
|
||||||
|
smtp_server: string
|
||||||
|
smtp_port: number
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
filter: {
|
||||||
|
subject_keywords: string[]
|
||||||
|
sender_email: string
|
||||||
|
oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }
|
||||||
|
oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }
|
||||||
|
}
|
||||||
|
wechat: {
|
||||||
|
webhook_url: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailInfo {
|
||||||
|
msg_id: string
|
||||||
|
subject: string
|
||||||
|
sender: string
|
||||||
|
date: string
|
||||||
|
html: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FaultInfo {
|
||||||
|
fault_time: string | null
|
||||||
|
server_sn: string | null
|
||||||
|
server_ip: string | null
|
||||||
|
order_number: string | null
|
||||||
|
fault_detail: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketCreateInput {
|
||||||
|
ticket_no: string
|
||||||
|
device_ip: string | null
|
||||||
|
device_sn: string | null
|
||||||
|
device_name: string | null
|
||||||
|
content: string | null
|
||||||
|
assign_time: string
|
||||||
|
ticket_type: string | null
|
||||||
|
fault_category: string | null
|
||||||
|
fault_subcategory: string | null
|
||||||
|
responsibility: string | null
|
||||||
|
current_status: string
|
||||||
|
counted_in_sla: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TierDeadline {
|
||||||
|
deadline: string
|
||||||
|
hours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTC+8 北京时间格式化(禁止使用 toISOString)
|
||||||
|
export function formatBeijingTime(date: Date = new Date()): string {
|
||||||
|
const d = new Date(date.getTime() + 8 * 3600_000)
|
||||||
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')} ${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}:${String(d.getUTCSeconds()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
// src/lib/monitor/wechat-pusher.ts
|
||||||
|
import type { MonitorConfig } from './types'
|
||||||
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string) => console.log(`[Worker] ${formatBeijingTime()} INFO ${msg}`),
|
||||||
|
error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WeChatPusher {
|
||||||
|
async pushText(text: string, webhookUrl: string): Promise<boolean> {
|
||||||
|
if (!webhookUrl) { logger.error('Webhook URL not configured'); return false }
|
||||||
|
try {
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ msgtype: 'text', text: { content: text } }),
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
|
})
|
||||||
|
if (!response.ok) { logger.error(`Webhook HTTP ${response.status}`); return false }
|
||||||
|
const result = await response.json() as { errcode?: number; errmsg?: string }
|
||||||
|
if (result.errcode === 0) { logger.info('WeChat message sent'); return true }
|
||||||
|
logger.error(`WeChat error: ${result.errmsg}`)
|
||||||
|
return false
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Webhook failed: ${e instanceof Error ? e.message : e}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatAvailabilityMessage(
|
||||||
|
deadlines: Record<string, { deadline: string; hours: number }>,
|
||||||
|
faultInfo: { server_ip: string | null; server_sn: string | null; fault_time: string | null },
|
||||||
|
isOemDiag: boolean,
|
||||||
|
oemDeadline: string | null,
|
||||||
|
orderNumber: string | null,
|
||||||
|
rackPosition: string | null,
|
||||||
|
faultDetail: string | null,
|
||||||
|
): string {
|
||||||
|
let msg = ''
|
||||||
|
const typeLabel = isOemDiag ? 'OEM诊断' : 'OEM维修'
|
||||||
|
msg += `【服务器故障单】 - ${typeLabel}\n\n`
|
||||||
|
if (orderNumber) msg += `工单号:${orderNumber}\n`
|
||||||
|
msg += `服务器IP:${faultInfo.server_ip || '未知'}\n`
|
||||||
|
msg += `服务器SN:${faultInfo.server_sn || '未知'}\n`
|
||||||
|
msg += `机架位置:${rackPosition || '未知'}\n`
|
||||||
|
if (faultInfo.fault_time) msg += `故障时间:${faultInfo.fault_time}\n`
|
||||||
|
if (faultDetail) msg += `故障信息:${faultDetail}\n`
|
||||||
|
|
||||||
|
if (isOemDiag && oemDeadline) {
|
||||||
|
msg += `到期时间:${oemDeadline}\n`
|
||||||
|
} else if (!isOemDiag) {
|
||||||
|
msg += '\n📊 服务可用性各档位截止时间:\n\n'
|
||||||
|
const tierDesc: Record<string, string> = {
|
||||||
|
'第一档': '99%(含)以上(不扣费)',
|
||||||
|
'第二档': '99%-97%(含)(扣10%)',
|
||||||
|
'第三档': '97%-95%(含)(扣25%)',
|
||||||
|
'第四档': '95%-90%(含)(扣50%)',
|
||||||
|
'第五档': '90%(不含)以下(扣100%)',
|
||||||
|
}
|
||||||
|
const emojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣']
|
||||||
|
let i = 0
|
||||||
|
for (const [tier, desc] of Object.entries(tierDesc)) {
|
||||||
|
const d = deadlines[tier]
|
||||||
|
if (d) {
|
||||||
|
msg += `${emojis[i]} ${desc}\n`
|
||||||
|
msg += ` 截止时间:${d.deadline}`
|
||||||
|
if (d.hours > 0) msg += `(${d.hours}小时)`
|
||||||
|
msg += '\n\n'
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
// src/lib/monitor/worker.ts
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import cron from 'node-cron'
|
||||||
|
import { MailMonitor } from './mail-monitor'
|
||||||
|
import { TicketProcessor } from './ticket-processor'
|
||||||
|
import { WeChatPusher } from './wechat-pusher'
|
||||||
|
import { AvailabilityEngine } from './availability-engine'
|
||||||
|
import { ReminderEngine } from './reminder-engine'
|
||||||
|
import { getMonitorConfig, getSetting, setSetting } from './settings-manager'
|
||||||
|
import type { MonitorConfig } from './types'
|
||||||
|
import { formatBeijingTime } from './types'
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
info: (msg: string) => console.log(`[Worker] ${formatBeijingTime()} INFO ${msg}`),
|
||||||
|
error: (msg: string) => console.error(`[Worker] ${formatBeijingTime()} ERROR ${msg}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BackgroundWorker {
|
||||||
|
private mailMonitor = new MailMonitor()
|
||||||
|
private ticketProcessor = new TicketProcessor()
|
||||||
|
private wechatPusher = new WeChatPusher()
|
||||||
|
private availabilityEngine = new AvailabilityEngine()
|
||||||
|
private reminderEngine = new ReminderEngine()
|
||||||
|
private processingPromise: Promise<void> | null = null
|
||||||
|
cronJob: any = null
|
||||||
|
private lastTickTime = 0
|
||||||
|
private db = getDb()
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.cronJob) return
|
||||||
|
this.cronJob = cron.schedule('* * * * *', () => { this.tick() })
|
||||||
|
logger.info('Cron job started (every 60 seconds)')
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.cronJob) {
|
||||||
|
this.cronJob.stop()
|
||||||
|
this.cronJob = null
|
||||||
|
}
|
||||||
|
this.mailMonitor.disconnect()
|
||||||
|
logger.info('Worker stopped')
|
||||||
|
}
|
||||||
|
|
||||||
|
async tick(): Promise<void> {
|
||||||
|
if (this.processingPromise) return
|
||||||
|
this.processingPromise = this.doTick()
|
||||||
|
try { await this.processingPromise }
|
||||||
|
finally { this.processingPromise = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doTick(): Promise<void> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
try {
|
||||||
|
const config = getMonitorConfig()
|
||||||
|
if (!config.monitor.enabled) return
|
||||||
|
|
||||||
|
// 检查是否被 API 手动触发
|
||||||
|
const triggerRequested = getSetting('monitor.trigger_requested') === 'true'
|
||||||
|
if (triggerRequested) {
|
||||||
|
setSetting('monitor.trigger_requested', 'false', 'monitor')
|
||||||
|
// 立即执行,忽略 interval_seconds
|
||||||
|
} else if (Date.now() - this.lastTickTime < config.monitor.interval_seconds * 1000) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.lastTickTime = Date.now()
|
||||||
|
|
||||||
|
// Step 3: 检查恢复
|
||||||
|
await this.checkRecoveryUpdates(config)
|
||||||
|
|
||||||
|
// Step 4: 7 点 flush
|
||||||
|
if (this.reminderEngine.isTimeToFlush()) {
|
||||||
|
await this.reminderEngine.flushPendingMessages((text) => this.wechatPusher.pushText(text, config.wechat.webhook_url))
|
||||||
|
this.reminderEngine.markFlushed()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5-6: 检查提醒
|
||||||
|
const pushText = (text: string) => this.wechatPusher.pushText(text, config.wechat.webhook_url)
|
||||||
|
const getRack = (ip: string | null, sn: string | null) => this.ticketProcessor.getRackPosition(ip, sn)
|
||||||
|
await this.reminderEngine.checkOemDiagReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
||||||
|
await this.reminderEngine.checkOemRepairReminders(pushText, config.monitor.push_delay_ms, getRack, config)
|
||||||
|
|
||||||
|
// Step 7: IMAP 连接
|
||||||
|
if (!this.mailMonitor.isConnected()) {
|
||||||
|
try { await this.mailMonitor.connect(config.mail) }
|
||||||
|
catch (e) { await this.mailMonitor.reconnect(config.mail) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8-9: 搜索并处理邮件
|
||||||
|
const emails = await this.mailMonitor.fetchUnread(config)
|
||||||
|
let processed = 0, errors = 0
|
||||||
|
|
||||||
|
for (const email of emails) {
|
||||||
|
try {
|
||||||
|
// 去重检查
|
||||||
|
const existing = this.db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(email.msg_id)
|
||||||
|
if (existing) continue
|
||||||
|
|
||||||
|
// 过滤
|
||||||
|
const { pass, type } = this.mailMonitor.filterEmail(email, config.filter)
|
||||||
|
if (!pass || !type) continue
|
||||||
|
|
||||||
|
// 提取表格和故障信息
|
||||||
|
const tableData = email.html ? this.mailMonitor.extractTable(email.html) : null
|
||||||
|
const faultInfo = email.html ? this.mailMonitor.extractFaultInfo(email.html, tableData) : null
|
||||||
|
if (!faultInfo?.order_number || !faultInfo?.fault_time) continue
|
||||||
|
|
||||||
|
// 创建工单
|
||||||
|
const content = faultInfo.fault_detail || ''
|
||||||
|
this.ticketProcessor.createTicketFromEmail(faultInfo, type, config, content)
|
||||||
|
|
||||||
|
// 获取机架位置
|
||||||
|
const rackPosition = await this.ticketProcessor.getRackPosition(faultInfo.server_ip, faultInfo.server_sn)
|
||||||
|
|
||||||
|
// 计算可用性
|
||||||
|
const monthKey = faultInfo.fault_time.slice(0, 7)
|
||||||
|
let deadlines: Record<string, { deadline: string; hours: number }> = {}
|
||||||
|
let oemDeadline: string | null = null
|
||||||
|
if (type === 'oem_repair') {
|
||||||
|
deadlines = this.availabilityEngine.calculateTierDeadlines(monthKey, faultInfo.server_sn!, faultInfo.fault_time)
|
||||||
|
} else {
|
||||||
|
oemDeadline = this.availabilityEngine.calculateOemDiagDeadline(monthKey, faultInfo.fault_time)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送微信
|
||||||
|
const message = this.wechatPusher.formatAvailabilityMessage(deadlines, faultInfo, type === 'oem_diag', oemDeadline, faultInfo.order_number, rackPosition, faultInfo.fault_detail)
|
||||||
|
await this.wechatPusher.pushText(message, config.wechat.webhook_url)
|
||||||
|
|
||||||
|
// 记录故障
|
||||||
|
this.ticketProcessor.recordFault({
|
||||||
|
server_sn: faultInfo.server_sn!, server_ip: faultInfo.server_ip, order_number: faultInfo.order_number,
|
||||||
|
fault_type: type, fault_time: faultInfo.fault_time!, fault_detail: faultInfo.fault_detail, status: 'ongoing',
|
||||||
|
recovery_time: null, duration_seconds: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建提醒
|
||||||
|
if (type === 'oem_diag' && oemDeadline) {
|
||||||
|
this.reminderEngine.createReminder({
|
||||||
|
server_sn: faultInfo.server_sn!, server_ip: faultInfo.server_ip, order_number: faultInfo.order_number,
|
||||||
|
reminder_type: 'oem_diag', deadline_time: oemDeadline, deadline_hours: null,
|
||||||
|
fault_time: faultInfo.fault_time, tier_name: null, notification_phase: 'none', notified_at: null,
|
||||||
|
})
|
||||||
|
} else if (type === 'oem_repair') {
|
||||||
|
for (const [tierName, tierData] of Object.entries(deadlines)) {
|
||||||
|
this.reminderEngine.createReminder({
|
||||||
|
server_sn: faultInfo.server_sn!, server_ip: faultInfo.server_ip, order_number: faultInfo.order_number,
|
||||||
|
reminder_type: 'oem_repair', deadline_time: tierData.deadline, deadline_hours: tierData.hours,
|
||||||
|
fault_time: faultInfo.fault_time, tier_name: tierName, notification_phase: 'none', notified_at: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记已处理
|
||||||
|
this.db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(email.msg_id, email.subject)
|
||||||
|
processed++
|
||||||
|
} catch (e) {
|
||||||
|
errors++
|
||||||
|
logger.error(`Email error: ${e instanceof Error ? e.message : e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOOP 保活
|
||||||
|
await this.mailMonitor.keepAlive()
|
||||||
|
|
||||||
|
// 日志清理(每天北京时间 3 点)
|
||||||
|
const nowBeijing = formatBeijingTime()
|
||||||
|
const hour = parseInt(nowBeijing.slice(11, 13))
|
||||||
|
const minute = parseInt(nowBeijing.slice(14, 16))
|
||||||
|
if (hour === 3 && minute === 0) {
|
||||||
|
this.db.prepare("DELETE FROM monitor_logs WHERE created_at < datetime('now', '+8 hours', '-90 days')").run()
|
||||||
|
this.db.prepare("DELETE FROM processed_emails WHERE processed_at < datetime('now', '+8 hours', '-90 days')").run()
|
||||||
|
this.db.prepare("DELETE FROM pending_messages WHERE created_at < datetime('now', '+8 hours', '-24 hours')").run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录 tick
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
this.db.prepare("INSERT INTO monitor_logs (action, details) VALUES (?, ?)").run(
|
||||||
|
'tick_complete', JSON.stringify({ processed, errors, duration, timestamp: formatBeijingTime() })
|
||||||
|
)
|
||||||
|
if (processed > 0) logger.info(`Tick complete: ${processed} processed, ${errors} errors, ${duration}ms`)
|
||||||
|
} catch (e) {
|
||||||
|
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||||
|
logger.error(`Tick error: ${errorMsg}`)
|
||||||
|
// 将错误写入数据库,便于状态 API 检测
|
||||||
|
try {
|
||||||
|
this.db.prepare("INSERT INTO monitor_logs (action, details) VALUES (?, ?)").run(
|
||||||
|
'error', JSON.stringify({ error: errorMsg, timestamp: formatBeijingTime() })
|
||||||
|
)
|
||||||
|
} catch { /* 忽略日志写入失败 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkRecoveryUpdates(config: MonitorConfig): Promise<void> {
|
||||||
|
const ongoing = this.db.prepare("SELECT * FROM fault_records WHERE status = 'ongoing'").all() as { id: number; order_number: string | null; server_sn: string; server_ip: string | null; fault_time: string }[]
|
||||||
|
for (const fault of ongoing) {
|
||||||
|
if (!fault.order_number) continue
|
||||||
|
const ticket = this.db.prepare('SELECT current_status, close_time FROM tickets WHERE id = ?').get(parseInt(fault.order_number)) as { current_status: string; close_time: string | null } | undefined
|
||||||
|
if (ticket && ['resolved', 'closed'].includes(ticket.current_status)) {
|
||||||
|
const recoveryTime = ticket.close_time || formatBeijingTime(new Date())
|
||||||
|
const faultDate = new Date(fault.fault_time.replace(' ', 'T') + '+08:00')
|
||||||
|
const recoveryDate = new Date(recoveryTime.replace(' ', 'T') + '+08:00')
|
||||||
|
const duration = Math.floor((recoveryDate.getTime() - faultDate.getTime()) / 1000)
|
||||||
|
this.db.prepare("UPDATE fault_records SET status = 'resolved', recovery_time = ?, duration_seconds = ? WHERE id = ?").run(recoveryTime, duration, fault.id)
|
||||||
|
this.reminderEngine.cleanupForTicket(fault.order_number)
|
||||||
|
// 发送结单提醒
|
||||||
|
await this.wechatPusher.pushText(`✅ ${fault.order_number}工单已结单\n\n服务器IP:${fault.server_ip || '未知'}\n服务器SN:${fault.server_sn}\n结单时间:${recoveryTime}\n本次处理时长:${Math.floor(duration / 3600)}小时${Math.floor((duration % 3600) / 60)}分钟`, config.wechat.webhook_url)
|
||||||
|
logger.info(`Recovery detected: ${fault.order_number}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
declare module 'mailparser' {
|
||||||
|
import { Readable } from 'stream'
|
||||||
|
|
||||||
|
interface ParsedMail {
|
||||||
|
messageId?: string
|
||||||
|
subject?: string
|
||||||
|
from?: { text: string }
|
||||||
|
date?: Date
|
||||||
|
html?: string | false
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleParser(source: Buffer | string | Readable): Promise<ParsedMail>
|
||||||
|
|
||||||
|
export { simpleParser, ParsedMail }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": ".next/standalone",
|
||||||
|
"declaration": false,
|
||||||
|
"noEmit": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "es2020"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"scripts/monitor-worker.ts",
|
||||||
|
"src/lib/monitor/**/*.ts",
|
||||||
|
"src/lib/db.ts",
|
||||||
|
"src/lib/db-schema.ts",
|
||||||
|
"src/lib/assets-client.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue