Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8627228bbc | ||
|
|
406b3744aa | ||
|
|
6da5782ac7 | ||
|
|
c36709f366 | ||
|
|
564234b769 | ||
|
|
138297d191 | ||
|
|
309bffc7ff | ||
|
|
f67b33de33 | ||
|
|
f52e3c5fbc | ||
|
|
be53848dee | ||
|
|
32bba1bd84 | ||
|
|
ecb567c0da | ||
|
|
7ff019e829 |
@@ -4,11 +4,11 @@
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 当前任务:菜单展开 Logo 增加 SIP 文字并发布到 B
|
||||
- 本次变更摘要:已核对 `tests/reports/` 下 8.2-8.10 远程测试结果;修复客户充值接口负数扣款、active call hangup 超长/无分隔 ID 参数校验、Fastify 路由参数长度限制,以及既有环境号码库内置角色权限补齐迁移。追加修复业务前缀管理页面 `normalizeBusinessPrefix is not defined`、号码库页面 `emptyNumberLibraryRows is not defined`、客户网关编辑触发 React #137 的前端运行时错误。最新 s53 在菜单展开 Logo 后增加 `SIP` 文字,收起菜单仍展示 `logo2`,并发布 Web-only release。新增测试门禁:`pnpm lint` 覆盖 `apps/web/src/**`,`pnpm test:remote-web-ui` 使用真实浏览器登录并逐个点击核心菜单及每页一个安全主操作,客户网关编辑动作会验证业务前缀 checkbox 可 false->true->false 往返切换。B 当前 release 为 `s53-brand-logo-sip-text-20260630105500`;准备提交 Git。
|
||||
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`;B 二期冻结基线为 `s42-phase2-business-prefix-20260624140000`,当前 release 为 `s53-brand-logo-sip-text-20260630105500`;2026-06-30 开机后已恢复 A/B/T 服务:A `opensips`、`rtpengine-daemon`、`rtpengine-recording-daemon`、`lisglosips-redis-auth-proxy`、`lisglosips-node-exporter`、`lisglosips-recording-finalize.timer` active,`lisglosips-redis-hotpath-load.service` Result=success;B `mysql`、`redis-server`、`nginx`、`lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker`、`lisglosips@config-publisher`、`heplify-server`、`lisglosips-prometheus`、`grafana-server` active,preflight ok,API ready ok;T `opensips`、`rtpengine-daemon`、`rtpengine-recording-daemon`、`lisglosips-s28-uas`、`apache2`、`mariadb/mysql` active;三机 `systemctl --failed` 均为 0。B preflight、远程 smoke、8.2/8.7/8.8 定向复测均通过;业务前缀、号码库、客户网关编辑与前缀选择 Web-only 修复后,s53 当前首页引用 Web JS `index-CrjpbrU-.js`、CSS `index-CrJGI5_R.css`,并包含 `/favicon.ico`、`/brand/logo1.png`、`/brand/logo2.png`;远程 smoke `REMOTE_SMOKE_20260630T031146Z.md` 通过;完整按钮级远程浏览器巡检 `REMOTE_WEB_UI_SMOKE_20260629T104040Z.md` 通过,覆盖 15 个核心菜单及安全主操作,客户网关管理编辑报告包含 `business-prefix-checkbox-toggle-ok: false->true->false`;B 本机号码库四个列表接口均返回 200,当前 total 均为 0,符合真实号段尚未导入状态;本地代码验证 `lint`、`typecheck`、全量 `test`、`build`、`node --check tests/web/remote-web-ui-smoke.mjs` 均通过;Redis `cfg:active_version=s28-v1`,A 当前 EVALSHA 脚本在 Redis 中存在;A 已加载二期 Lua 热路径 SHA1 `aff9995dbda2a2425e95a3e25b5d7ce194348ce7`;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练
|
||||
- 最后更新:2026-06-30 11:12 +08:00
|
||||
- 当前阻塞:本轮 8.2/8.7/8.8、业务前缀页面、号码库页面、客户网关编辑运行时错误、测试门禁修复、B SSH/HTTPS 来源限制移除无阻塞。遗留问题:未提交 Git;未改 A OpenSIPS/Lua/RTPEngine 或 T 配置;8.9 轻量 API burst p95 超过 10 秒需结合服务端日志和测试方式继续定位;SIP CPS 探针中 BYE 被 `403 Rate Limited` 属 A 侧限流策略问题,修复前需说明影响、备份 A 配置并安排维护窗口;8.10 备份、隔离恢复、真实回滚、OpenSIPS 配置恢复受权限或维护窗口阻塞;真实 80 万手机号段尚未导入;OpenSIPS dialog 残留根因仍待单独分析。
|
||||
- 当前任务:`S56 - 全部当前服务收敛到 Server B`
|
||||
- 本次变更摘要:确认 B 已同时运行 OpenSIPS、RTPEngine、录音守护进程、Web/API/Worker、MySQL、Redis 与监控;将 Recording Worker 从 B 到 A 的 SSH 拉取改为 B 本机 ready 目录读取,安装本机 finalizer timer,并通过 systemd BindPaths/附加组暴露最小目录权限。当前 release 为 `s56-b-consolidation-20260827T095812Z`,提交为 `309bffc7ffa70a760bce21ae59cb6c457c9c57fe`。
|
||||
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`;B 二期冻结基线为 `s42-phase2-business-prefix-20260624140000`,当前 release 为 `s53-brand-logo-sip-text-20260630105500`;稳定版名称 `20260630release`,Git tag `20260630release` 和分支 `stable/20260630release` 均指向稳定代码提交 `9920575bbae7fbef4929843632f94d687ffbe289`。2026-06-30 开机后已恢复 A/B/T 服务:A `opensips`、`rtpengine-daemon`、`rtpengine-recording-daemon`、`lisglosips-redis-auth-proxy`、`lisglosips-node-exporter`、`lisglosips-recording-finalize.timer` active,`lisglosips-redis-hotpath-load.service` Result=success;B `mysql`、`redis-server`、`nginx`、`lisglosips@api`、`lisglosips@cdr-worker`、`lisglosips@recording-worker`、`lisglosips@config-publisher`、`heplify-server`、`lisglosips-prometheus`、`grafana-server` active,preflight ok,API ready ok,封版 MySQL 备份 `/data/backups/mysql/20260630T061723Z`,Redis 备份 `/data/backups/redis/20260630T061723Z`;T `opensips`、`rtpengine-daemon`、`rtpengine-recording-daemon`、`lisglosips-s28-uas`、`apache2`、`mariadb/mysql` active。B preflight、远程 smoke、8.2/8.7/8.8 定向复测均通过;业务前缀、号码库、客户网关编辑与前缀选择 Web-only 修复后,s53 当前首页引用 Web JS `index-CrjpbrU-.js`、CSS `index-CrJGI5_R.css`,并包含 `/favicon.ico`、`/brand/logo1.png`、`/brand/logo2.png`;封版远程 smoke `REMOTE_SMOKE_20260630T061636Z.md` 通过;完整按钮级远程浏览器巡检 `REMOTE_WEB_UI_SMOKE_20260629T104040Z.md` 通过,覆盖 15 个核心菜单及安全主操作,客户网关管理编辑报告包含 `business-prefix-checkbox-toggle-ok: false->true->false`;封版报告为 `docs/20260630_RELEASE_BASELINE_REPORT.md`;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练
|
||||
- 最后更新:2026-08-27 18:16 +08:00
|
||||
- 当前阻塞:无发布阻塞。尚未发起新的真实收费/外呼来生成生产录音,因此真实 `incoming -> ready -> /data/recordings -> recordings READY -> 鉴权播放` 仍需在下一次受控呼叫时补验;本次未修改 TLS、数据库结构或 A/T 配置。
|
||||
- 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云
|
||||
- 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
| 代号 | 地址 | 用户 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| A | `100.90.90.90` | `hector` | OpenSIPS、RTPEngine、录音内存盘、HEP、Exporter |
|
||||
| B | `100.90.90.91` | `hector` | API、Worker、Redis、MySQL、录音、HOMER、Prometheus/Grafana |
|
||||
| A | `100.90.90.90` | `hector` | 历史节点/回滚参考;S56 起不参与当前生产服务或录音传输 |
|
||||
| B | `100.90.90.91` | `hector` | OpenSIPS、RTPEngine、录音、Nginx、API、Worker、Redis、MySQL、HOMER、Prometheus/Grafana |
|
||||
| T | `100.93.185.30` | `hector` | 已安装 OpenSIPS,模拟客户 SIP 注册与呼叫 |
|
||||
|
||||
## 任务状态表
|
||||
@@ -90,6 +90,27 @@
|
||||
|
||||
## 交接记录
|
||||
|
||||
### 2026-08-27 15:50 - UI 性能整改提交、推送并发布 B s55
|
||||
|
||||
- 状态:已完成,存在一项与本次代码无关的环境告警
|
||||
- 操作服务器:B、本地;未修改 A/T、TLS、数据库 schema、Redis 配置或 SIP 路由
|
||||
- 完成内容:提交并推送 `ecb567c0dacd749607808fbed09648267757be6f`;B 从精确 Git bundle 在 Linux 上构建并生成固定 artifact,发布为 `/opt/lisglosips/releases/s55-ui-performance-20260827153500`,由 s54 原子切换到 s55。Dashboard 使用 overview 聚合接口,话单默认 25 条并行查询,页面统一 2 秒加载弹窗和 10 秒请求超时。
|
||||
- 发布证据:artifact SHA256 `acc49bb21506448527732e965c345a5e71e83090781be76df750a93cffccaece`,19,681 个文件;MySQL/Redis 备份均为 `/data/backups/*/20260827T073857Z` 且 SHA 校验通过;B preflight、API ready、Nginx 配置与外部 smoke 通过,新首页引用 `index-DcpB-OOG.js` 和 `index-u-EPfKoT.css`。
|
||||
- 性能回读:真实管理员认证后,`/dashboard/overview` 外部耗时 1,843ms、24 个趋势桶;`/cdrs?take=25` 外部耗时 2,357ms,返回 25/97 条,较整改前约 5.5 秒下降,但仍会按设计在 2 秒后显示加载弹窗。
|
||||
- 验证结果:本地 ESLint、TypeScript、Prisma validate、95 条 Vitest、Web build 通过;B Linux workspace build、release artifact、服务 preflight、匿名 smoke、真实认证 Dashboard/CDR 只读检查通过。完整 Playwright UI 巡检因本机缺少 Chromium 二进制未执行,未临时安装软件。
|
||||
- 回滚方式:`sudo /opt/lisglosips/current/infra/server-b/s30/lisglosips-release-rollback.sh /opt/lisglosips/releases/s54-cmpp-ui-style-20260702103305`;发布脚本自身带失败自动回切,本次未触发。
|
||||
- 环境告警:B 到 A `100.90.90.90` 的 Tailscale ping 和录音专用 SSH 均超时,导致 recording-worker 约每 135 秒由 systemd 自动重启;本次未改 worker 源码,B 其他服务均 active。需 A 恢复在线后复核录音 Worker,不应通过回滚 s55 掩盖 A 不可达问题。
|
||||
|
||||
### 2026-06-30 14:20 - `20260630release` 稳定版封版
|
||||
|
||||
- 状态:已完成
|
||||
- 操作服务器:本地、A、B、T;未改业务代码,未发布新 release,未改数据库 schema
|
||||
- 完成内容:将稳定代码提交 `9920575bbae7fbef4929843632f94d687ffbe289` 打 tag `20260630release`,创建分支 `stable/20260630release`;触发 B `lisglosips-backup.service` 生成封版 MySQL/Redis 备份;采集 A/B/T 服务状态、OpenSIPS 配置校验和关键 SHA;生成封版报告 `docs/20260630_RELEASE_BASELINE_REPORT.md`。
|
||||
- 修改文件:`docs/20260630_RELEASE_BASELINE_REPORT.md`、`IMPLEMENTATION_STATUS.md`、新增 smoke 报告 `tests/reports/REMOTE_SMOKE_20260630T061636Z.md`
|
||||
- 验证结果:B current 为 `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500`;B backup result `success`,MySQL 备份 `/data/backups/mysql/20260630T061723Z`,Redis 备份 `/data/backups/redis/20260630T061723Z`;B preflight ok;远程 smoke `REMOTE_SMOKE_20260630T061636Z.md` 通过;A OpenSIPS 配置 SHA256 `f163fa37f7aaa492e12ed496cb82f07d9b0f1030a6ed297855a22d2c4bf1c8a5`,热路径 Lua SHA1 `aff9995dbda2a2425e95a3e25b5d7ce194348ce7`,热路径加载 `success`;T OpenSIPS 配置 SHA256 `5255dd540d33c3a7bf3db278fe6fe39540b176eb79e7fe12a7a26e5217c263da`;A/T `fwupd-refresh.service` failed 为既有无关遗留。
|
||||
- 回滚方式:代码回滚可执行 `git checkout 20260630release` 或 `git switch stable/20260630release`;B 应用回滚可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500` 并重启 API/Worker、reload Nginx;数据恢复使用封版备份前必须先在隔离环境验证。
|
||||
- 未解决问题:真实 80 万手机号段尚未导入;8.9/8.10 既有遗留问题未处理;A/T `fwupd-refresh.service` failed 未处理。
|
||||
|
||||
### 2026-06-30 11:12 - Web-only s53 菜单展开 Logo 增加 SIP
|
||||
|
||||
- 状态:已完成
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { RuntimeConfig } from '../../shared/config.js';
|
||||
import { OpenSipsMiClient } from './opensips-mi.client.js';
|
||||
|
||||
function client() {
|
||||
const config = {
|
||||
get: () => ({
|
||||
mode: 'http',
|
||||
httpUrl: 'http://127.0.0.1:8888/mi',
|
||||
sshHost: 'unused',
|
||||
remoteCommand: 'unused',
|
||||
timeoutMs: 3000
|
||||
})
|
||||
} as unknown as ConfigService<RuntimeConfig, true>;
|
||||
return new OpenSipsMiClient(config);
|
||||
}
|
||||
|
||||
describe('OpenSipsMiClient HTTP MI', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('lists dialogs through the local JSON-RPC endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { Dialogs: [] } }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(client().listDialogs()).resolves.toEqual({ Dialogs: [] });
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8888/mi', expect.objectContaining({ method: 'POST' }));
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ jsonrpc: '2.0', id: 1, method: 'dlg_list', params: [] });
|
||||
});
|
||||
|
||||
it('preserves OpenSIPS MI errors for the API layer', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: { code: 500, message: 'Operation failed' } }), { status: 200 })));
|
||||
await expect(client().endDialog('dlg-1')).rejects.toMatchObject({ response: expect.objectContaining({ code: 'ACTIVE_CALLS_MI_ERROR' }) });
|
||||
});
|
||||
});
|
||||
@@ -63,17 +63,13 @@ export class OpenSipsMiClient {
|
||||
|
||||
private async call(method: string, params: string[] = []): Promise<unknown> {
|
||||
const config = this.configService.get('activeCalls', { infer: true });
|
||||
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
|
||||
const args = [];
|
||||
if (config.sshConfig) {
|
||||
args.push('-F', config.sshConfig);
|
||||
}
|
||||
args.push(config.sshHost, remoteCommand);
|
||||
|
||||
let output: string;
|
||||
let response: MiResponse;
|
||||
try {
|
||||
output = await this.executor.run(args, config.timeoutMs);
|
||||
response = config.mode === 'http'
|
||||
? await this.callHttp(config.httpUrl, method, params, config.timeoutMs)
|
||||
: await this.callSsh(config, method, params);
|
||||
} catch (error) {
|
||||
if (error instanceof BadGatewayException) throw error;
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_UNAVAILABLE',
|
||||
message: 'OpenSIPS control plane is unavailable.',
|
||||
@@ -81,16 +77,6 @@ export class OpenSipsMiClient {
|
||||
});
|
||||
}
|
||||
|
||||
let response: MiResponse;
|
||||
try {
|
||||
response = JSON.parse(output) as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
|
||||
if (response.error) {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_ERROR',
|
||||
@@ -101,6 +87,42 @@ export class OpenSipsMiClient {
|
||||
|
||||
return response.result;
|
||||
}
|
||||
|
||||
private async callHttp(url: string, method: string, params: string[], timeoutMs: number): Promise<MiResponse> {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
signal: AbortSignal.timeout(timeoutMs)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenSIPS MI HTTP returned ${response.status}`);
|
||||
}
|
||||
try {
|
||||
return await response.json() as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async callSsh(config: RuntimeConfig['activeCalls'], method: string, params: string[]): Promise<MiResponse> {
|
||||
const remoteCommand = [config.remoteCommand, method, ...params].map(shellQuote).join(' ');
|
||||
const args: string[] = [];
|
||||
if (config.sshConfig) args.push('-F', config.sshConfig);
|
||||
args.push(config.sshHost, remoteCommand);
|
||||
const output = await this.executor.run(args, config.timeoutMs);
|
||||
try {
|
||||
return JSON.parse(output) as MiResponse;
|
||||
} catch {
|
||||
throw new BadGatewayException({
|
||||
code: 'ACTIVE_CALLS_MI_INVALID_RESPONSE',
|
||||
message: 'OpenSIPS control plane returned invalid JSON.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@lisglosips/database';
|
||||
import { PrismaService } from '../database/prisma.service.js';
|
||||
|
||||
export interface AuditLogQuery {
|
||||
@@ -8,6 +9,8 @@ export interface AuditLogQuery {
|
||||
objectType?: string;
|
||||
objectId?: string;
|
||||
result?: 'SUCCESS' | 'FAILURE';
|
||||
createdFrom?: Date;
|
||||
createdTo?: Date;
|
||||
take: number;
|
||||
skip: number;
|
||||
}
|
||||
@@ -46,13 +49,14 @@ export class PrismaAuditLogsRepository implements AuditLogsRepository {
|
||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: AuditLogQuery): Promise<{ items: AuditLogSummary[]; total: number }> {
|
||||
const where = {
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
module: query.module,
|
||||
action: query.action,
|
||||
userId: query.userId,
|
||||
objectType: query.objectType,
|
||||
objectId: query.objectId,
|
||||
result: query.result
|
||||
result: query.result,
|
||||
createdAt: query.createdFrom || query.createdTo ? { gte: query.createdFrom, lte: query.createdTo } : undefined
|
||||
};
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
|
||||
@@ -13,6 +13,8 @@ export class AuditLogsService {
|
||||
objectType: this.optionalString(rawQuery.objectType),
|
||||
objectId: this.optionalString(rawQuery.objectId),
|
||||
result: rawQuery.result === undefined ? undefined : this.result(rawQuery.result),
|
||||
createdFrom: this.optionalDate(rawQuery.createdFrom, 'createdFrom'),
|
||||
createdTo: this.optionalDate(rawQuery.createdTo, 'createdTo'),
|
||||
take: this.positiveInt(rawQuery.take, 50, 100),
|
||||
skip: this.positiveInt(rawQuery.skip, 0, 10_000)
|
||||
};
|
||||
@@ -56,4 +58,16 @@ export class AuditLogsService {
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private optionalDate(value: unknown, field: string): Date | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException({ code: 'QUERY_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,10 +202,10 @@ export class PrismaCdrsRepository implements CdrsRepository {
|
||||
|
||||
async list(query: CdrQuery): Promise<{ items: CdrListItem[]; meta: CdrPageMeta }> {
|
||||
const where = this.where(query);
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.rawCdr.findMany({
|
||||
where,
|
||||
orderBy: [{ startedAt: 'desc' }],
|
||||
orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
|
||||
take: query.take,
|
||||
skip: query.skip,
|
||||
include: this.includeCdr()
|
||||
|
||||
@@ -75,6 +75,15 @@ class MemoryCdrsRepository implements CdrsRepository {
|
||||
}
|
||||
|
||||
describe('CDR service', () => {
|
||||
it('uses a 25-row default page to keep the first list response small', async () => {
|
||||
const repository = new MemoryCdrsRepository();
|
||||
const service = new CdrsService(repository);
|
||||
|
||||
await service.list({});
|
||||
|
||||
expect(repository.lastQuery).toMatchObject({ take: 25, skip: 0 });
|
||||
});
|
||||
|
||||
it('normalizes filters for city, carrier and time range queries', async () => {
|
||||
const repository = new MemoryCdrsRepository();
|
||||
const service = new CdrsService(repository);
|
||||
|
||||
@@ -15,7 +15,7 @@ export class CdrsService {
|
||||
carrier: rawQuery.carrier === undefined ? undefined : this.carrier(rawQuery.carrier),
|
||||
startedFrom: this.optionalDate(rawQuery.startedFrom ?? rawQuery.startTime ?? rawQuery.from),
|
||||
startedTo: this.optionalDate(rawQuery.startedTo ?? rawQuery.endTime ?? rawQuery.to),
|
||||
take: this.int(rawQuery.take, 100, 1, 500),
|
||||
take: this.int(rawQuery.take, 25, 1, 500),
|
||||
skip: this.int(rawQuery.skip, 0, 0, 1_000_000)
|
||||
};
|
||||
if (query.startedFrom && query.startedTo && query.startedFrom > query.startedTo) {
|
||||
|
||||
@@ -14,6 +14,12 @@ export class DashboardController {
|
||||
return this.dashboardService.summary();
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
@RequirePermissions('dashboard.view')
|
||||
overview(@Query() query: { hours?: string; bucketMinutes?: string }) {
|
||||
return this.dashboardService.overview(query);
|
||||
}
|
||||
|
||||
@Get('trends')
|
||||
@RequirePermissions('dashboard.view')
|
||||
trends(@Query() query: { hours?: string; bucketMinutes?: string }) {
|
||||
|
||||
@@ -4,6 +4,22 @@ import { buildTrendBuckets, callMetrics, DashboardService, startOfShanghaiDayUtc
|
||||
import type { DashboardRepository } from './dashboard.repository.js';
|
||||
|
||||
describe('dashboard service', () => {
|
||||
it('loads summary and trends together for the overview response', async () => {
|
||||
const repository = {
|
||||
summaryWindow: async () => ({
|
||||
cdrs: [], ratedCdrs: [], failureCodes: [], abnormalGateways: [],
|
||||
activeCustomers: 0, activeCustomerGateways: 0, activeVendorGateways: 0, pendingQuality: 0
|
||||
}),
|
||||
trendWindow: async () => ({ cdrs: [], ratedCdrs: [] })
|
||||
} as unknown as DashboardRepository;
|
||||
const service = new DashboardService(repository);
|
||||
|
||||
const result = await service.overview({ hours: 1, bucketMinutes: 60 });
|
||||
|
||||
expect(result.summary).toHaveProperty('calls.totalCalls', 0);
|
||||
expect(result.trends.buckets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses Asia/Shanghai day boundary for today summary', async () => {
|
||||
const calls: Array<{ start: Date; end: Date }> = [];
|
||||
const repository = {
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface DashboardTrendBucket {
|
||||
export class DashboardService {
|
||||
constructor(@Inject(DASHBOARD_REPOSITORY) private readonly dashboard: DashboardRepository) {}
|
||||
|
||||
async overview(query: { hours?: unknown; bucketMinutes?: unknown } = {}) {
|
||||
const [summary, trends] = await Promise.all([
|
||||
this.summary(),
|
||||
this.trends(query)
|
||||
]);
|
||||
return { summary, trends };
|
||||
}
|
||||
|
||||
async summary(now = new Date()) {
|
||||
const start = startOfShanghaiDayUtc(now);
|
||||
const snapshot = await this.dashboard.summaryWindow(start, now);
|
||||
|
||||
@@ -65,8 +65,16 @@ export interface SaveReviewInput {
|
||||
|
||||
export const RECORDINGS_REPOSITORY = Symbol('RECORDINGS_REPOSITORY');
|
||||
|
||||
export interface RecordingListQuery {
|
||||
status?: string;
|
||||
reviewStatus?: 'PENDING' | 'REVIEWED';
|
||||
limit?: number;
|
||||
startedFrom?: Date;
|
||||
startedTo?: Date;
|
||||
}
|
||||
|
||||
export interface RecordingsRepository {
|
||||
list(query?: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number }): Promise<RecordingListItem[]>;
|
||||
list(query?: RecordingListQuery): Promise<RecordingListItem[]>;
|
||||
getDetail(id: string): Promise<RecordingDetail>;
|
||||
getReadyForPlayback(id: string): Promise<RecordingPlayback>;
|
||||
saveReview(input: SaveReviewInput): Promise<RecordingReviewSummary>;
|
||||
@@ -99,12 +107,17 @@ type ReviewRecord = Prisma.QualityReviewGetPayload<{
|
||||
export class PrismaRecordingsRepository implements RecordingsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(query: { status?: string; reviewStatus?: 'PENDING' | 'REVIEWED'; limit?: number } = {}): Promise<RecordingListItem[]> {
|
||||
async list(query: RecordingListQuery = {}): Promise<RecordingListItem[]> {
|
||||
const take = Math.min(query.limit ?? 100, 500);
|
||||
const startedAt = query.startedFrom || query.startedTo ? { gte: query.startedFrom, lte: query.startedTo } : undefined;
|
||||
const recordings = await this.prisma.recording.findMany({
|
||||
where: {
|
||||
status: query.status ? (query.status as never) : 'READY',
|
||||
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined
|
||||
reviews: query.reviewStatus === 'PENDING' ? { none: {} } : query.reviewStatus === 'REVIEWED' ? { some: {} } : undefined,
|
||||
OR: startedAt ? [
|
||||
{ rawCdr: { is: { startedAt } } },
|
||||
{ rawCdrId: null, createdAt: startedAt }
|
||||
] : undefined
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
take,
|
||||
|
||||
@@ -26,11 +26,13 @@ export class RecordingsService {
|
||||
@Inject(QualityService) private readonly qualityService: QualityService
|
||||
) {}
|
||||
|
||||
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown } = {}) {
|
||||
async list(query: { status?: unknown; reviewStatus?: unknown; limit?: unknown; startedFrom?: unknown; startedTo?: unknown } = {}) {
|
||||
const recordings = await this.recordings.list({
|
||||
status: query.status === undefined ? undefined : this.recordingStatus(query.status),
|
||||
reviewStatus: query.reviewStatus === undefined ? undefined : this.reviewStatus(query.reviewStatus),
|
||||
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500)
|
||||
limit: query.limit === undefined ? undefined : this.integer(query.limit, 'limit', 1, 500),
|
||||
startedFrom: query.startedFrom === undefined ? undefined : this.date(query.startedFrom, 'startedFrom'),
|
||||
startedTo: query.startedTo === undefined ? undefined : this.date(query.startedTo, 'startedTo')
|
||||
});
|
||||
return Promise.all(recordings.map((recording) => this.withSampling(recording)));
|
||||
}
|
||||
@@ -123,6 +125,17 @@ export class RecordingsService {
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private date(value: unknown, field: string): Date {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException({ code: 'DATE_INVALID', message: `${field} is invalid.` });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
export function internalRecordingPath(storageKey: string): string {
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface RuntimeConfig {
|
||||
url: string;
|
||||
};
|
||||
activeCalls: {
|
||||
mode: 'http' | 'ssh';
|
||||
httpUrl: string;
|
||||
sshHost: string;
|
||||
sshConfig?: string;
|
||||
remoteCommand: string;
|
||||
@@ -47,6 +49,8 @@ export const validationSchema = Joi.object({
|
||||
LISGLOSIPS_REQUEST_ID_HEADER: Joi.string().default('x-request-id'),
|
||||
DATABASE_URL: Joi.string().uri({ scheme: ['mysql'] }).required(),
|
||||
REDIS_URL: Joi.string().uri({ scheme: ['redis', 'rediss'] }).required(),
|
||||
ACTIVE_CALLS_MODE: Joi.string().valid('http', 'ssh').default('http'),
|
||||
ACTIVE_CALLS_HTTP_URL: Joi.string().uri({ scheme: ['http', 'https'] }).default('http://127.0.0.1:8888/mi'),
|
||||
ACTIVE_CALLS_SSH_HOST: Joi.string().allow('').default('lisglosips-a'),
|
||||
ACTIVE_CALLS_SSH_CONFIG: Joi.string().allow('').optional(),
|
||||
ACTIVE_CALLS_REMOTE_COMMAND: Joi.string().default('/usr/local/sbin/lisglosips-call-control'),
|
||||
@@ -89,6 +93,8 @@ export function appConfig(): RuntimeConfig {
|
||||
url: process.env.REDIS_URL ?? ''
|
||||
},
|
||||
activeCalls: {
|
||||
mode: process.env.ACTIVE_CALLS_MODE === 'ssh' ? 'ssh' : 'http',
|
||||
httpUrl: process.env.ACTIVE_CALLS_HTTP_URL ?? 'http://127.0.0.1:8888/mi',
|
||||
sshHost: process.env.ACTIVE_CALLS_SSH_HOST || 'lisglosips-a',
|
||||
sshConfig: process.env.ACTIVE_CALLS_SSH_CONFIG || undefined,
|
||||
remoteCommand: process.env.ACTIVE_CALLS_REMOTE_COMMAND ?? '/usr/local/sbin/lisglosips-call-control',
|
||||
|
||||
+100
-41
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input } from './components/ui.jsx';
|
||||
import { Icon } from './components/layout.jsx';
|
||||
import { Icon, PageLoadingDialog } from './components/layout.jsx';
|
||||
import { api, explainApiError } from './api.js';
|
||||
import { dateRangeParams, lastSevenDays } from './utils/dateRange.js';
|
||||
import {
|
||||
formatCurrency,
|
||||
normalizeAuditLog,
|
||||
@@ -238,9 +239,13 @@ export default function App() {
|
||||
const [dashboardTrends, setDashboardTrends] = useState(null);
|
||||
const [activeCalls, setActiveCalls] = useState([]);
|
||||
const [activeCallsLoading, setActiveCallsLoading] = useState(false);
|
||||
const [activeCallsBlockingLoading, setActiveCallsBlockingLoading] = useState(false);
|
||||
const [activeCallsError, setActiveCallsError] = useState('');
|
||||
const [activeCallsUpdatedAt, setActiveCallsUpdatedAt] = useState(null);
|
||||
const [apiLoading, setApiLoading] = useState(true);
|
||||
const [apiError, setApiError] = useState('');
|
||||
const loadedPagesRef = useRef(new Set());
|
||||
const pageRequestRef = useRef({ id: 0, controller: null });
|
||||
const currentPermissions = useMemo(() => permissionSet(authUser), [authUser]);
|
||||
const canPermission = (permission) => can(currentPermissions, permission);
|
||||
const allowedNavGroups = useMemo(() => navGroups
|
||||
@@ -249,55 +254,99 @@ export default function App() {
|
||||
const ActivePage = pages[active];
|
||||
const activeLabel = useMemo(() => navGroups.flatMap((group) => group.items).find((item) => item.key === active)?.label, [active]);
|
||||
const canAccessPage = (key, permissions = currentPermissions) => canAll(permissions, PAGE_PERMISSIONS[key] || []);
|
||||
const refreshApi = async (user = authUser) => {
|
||||
const loadPageData = async (page = active, user = authUser, { force = false } = {}) => {
|
||||
const sourceUser = user && Array.isArray(user.permissions) ? user : authUser;
|
||||
if (!sourceUser) return;
|
||||
if (!force && loadedPagesRef.current.has(page)) {
|
||||
setApiLoading(false);
|
||||
setApiError('');
|
||||
return;
|
||||
}
|
||||
const granted = permissionSet(sourceUser);
|
||||
const skipped = Symbol('skipped');
|
||||
const loadIfAllowed = (permission, loader) => (can(granted, permission) ? loader() : Promise.resolve(skipped));
|
||||
pageRequestRef.current.controller?.abort('PAGE_CHANGED');
|
||||
const controller = new AbortController();
|
||||
const requestId = pageRequestRef.current.id + 1;
|
||||
pageRequestRef.current = { id: requestId, controller };
|
||||
setApiLoading(true);
|
||||
setApiError('');
|
||||
const [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList] = await Promise.allSettled([
|
||||
loadIfAllowed('dashboard.view', api.dashboardSummary),
|
||||
loadIfAllowed('dashboard.view', api.dashboardTrends),
|
||||
loadIfAllowed('customers.view', api.customers),
|
||||
loadIfAllowed('vendors.view', api.vendors),
|
||||
loadIfAllowed('customer_gateways.view', api.customerGateways),
|
||||
loadIfAllowed('vendor_gateways.view', api.vendorGateways),
|
||||
loadIfAllowed('line_groups.view', api.landingLineGroups),
|
||||
loadIfAllowed('recharges.view', api.recharges),
|
||||
loadIfAllowed('users.view', api.users),
|
||||
loadIfAllowed('roles.view', api.roles),
|
||||
loadIfAllowed('audit.view', api.auditLogs),
|
||||
]);
|
||||
const failures = [summary, trends, customerList, vendorList, customerGatewayList, vendorGatewayList, lineGroupList, rechargeList, userList, roleList, auditLogList].filter((result) => result.status === 'rejected');
|
||||
if (summary.status === 'fulfilled' && summary.value !== skipped) setDashboardSummary(summary.value);
|
||||
if (trends.status === 'fulfilled' && trends.value !== skipped) setDashboardTrends(trends.value);
|
||||
if (customerList.status === 'fulfilled' && customerList.value !== skipped) setCustomerRows(customerList.value.map(normalizeCustomer));
|
||||
if (vendorList.status === 'fulfilled' && vendorList.value !== skipped) setVendorRows(vendorList.value.map(normalizeVendor));
|
||||
if (customerGatewayList.status === 'fulfilled' && customerGatewayList.value !== skipped) setCustomerGatewayRows(customerGatewayList.value.map(normalizeCustomerGateway));
|
||||
if (vendorGatewayList.status === 'fulfilled' && vendorGatewayList.value !== skipped) setVendorGatewayRows(vendorGatewayList.value.map(normalizeVendorGateway));
|
||||
if (lineGroupList.status === 'fulfilled' && lineGroupList.value !== skipped) setLandingLineGroupRows(lineGroupList.value.map(normalizeLandingLineGroup));
|
||||
if (rechargeList.status === 'fulfilled' && rechargeList.value !== skipped) setRechargeRows(rechargeList.value.items.map(normalizeRecharge));
|
||||
if (userList.status === 'fulfilled' && userList.value !== skipped) setUserRows(userList.value.map(normalizeUser));
|
||||
if (roleList.status === 'fulfilled' && roleList.value !== skipped) setRoleRows(roleList.value.map(normalizeRole));
|
||||
if (auditLogList.status === 'fulfilled' && auditLogList.value !== skipped) setLogRows(auditLogList.value.items.map(normalizeAuditLog));
|
||||
if (failures.length) {
|
||||
setApiError(explainApiError(failures[0].reason));
|
||||
const signalOptions = { signal: controller.signal };
|
||||
const definitions = [];
|
||||
const add = (permission, load, apply) => {
|
||||
if (can(granted, permission)) definitions.push({ load, apply });
|
||||
};
|
||||
if (page === 'dashboard') {
|
||||
add('dashboard.view', () => api.dashboardOverview(undefined, signalOptions), (value) => {
|
||||
setDashboardSummary(value.summary);
|
||||
setDashboardTrends(value.trends);
|
||||
});
|
||||
} else if (page === 'customers') {
|
||||
add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
|
||||
} else if (page === 'vendors') {
|
||||
add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor)));
|
||||
} else if (page === 'customerGateways') {
|
||||
add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
|
||||
add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
|
||||
add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
|
||||
} else if (page === 'vendorGateways') {
|
||||
add('vendors.view', () => api.vendors(signalOptions), (rows) => setVendorRows(rows.map(normalizeVendor)));
|
||||
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
|
||||
} else if (page === 'vendorLineGroups') {
|
||||
add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
|
||||
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
|
||||
} else if (page === 'rechargeRecords') {
|
||||
add('recharges.view', () => api.recharges(signalOptions), (value) => setRechargeRows(value.items.map(normalizeRecharge)));
|
||||
} else if (page === 'users' || page === 'roles') {
|
||||
add('users.view', () => api.users(signalOptions), (rows) => setUserRows(rows.map(normalizeUser)));
|
||||
add('roles.view', () => api.roles(signalOptions), (rows) => setRoleRows(rows.map(normalizeRole)));
|
||||
} else if (page === 'operationLogs') {
|
||||
const range = lastSevenDays();
|
||||
add('audit.view', () => api.auditLogs(dateRangeParams(range.startDate, range.endDate, 'createdFrom', 'createdTo'), signalOptions), (value) => setLogRows(value.items.map(normalizeAuditLog)));
|
||||
} else if (page === 'cdr') {
|
||||
add('customer_gateways.view', () => api.customerGateways(signalOptions), (rows) => setCustomerGatewayRows(rows.map(normalizeCustomerGateway)));
|
||||
add('vendor_gateways.view', () => api.vendorGateways(signalOptions), (rows) => setVendorGatewayRows(rows.map(normalizeVendorGateway)));
|
||||
} else if (page === 'qualityRuleDependencies') {
|
||||
add('customers.view', () => api.customers(signalOptions), (rows) => setCustomerRows(rows.map(normalizeCustomer)));
|
||||
add('line_groups.view', () => api.landingLineGroups(signalOptions), (rows) => setLandingLineGroupRows(rows.map(normalizeLandingLineGroup)));
|
||||
}
|
||||
const results = await Promise.allSettled(definitions.map((definition) => definition.load()));
|
||||
if (pageRequestRef.current.id !== requestId) return;
|
||||
const failures = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') definitions[index].apply(result.value);
|
||||
else if (!controller.signal.aborted) failures.push(result.reason);
|
||||
});
|
||||
if (failures.length) setApiError(explainApiError(failures[0]));
|
||||
else loadedPagesRef.current.add(page);
|
||||
setApiLoading(false);
|
||||
};
|
||||
const refreshActiveCalls = async () => {
|
||||
const refreshApi = () => loadPageData(active, authUser, { force: true });
|
||||
const refreshAuditLogs = async (params = {}) => {
|
||||
setApiLoading(true);
|
||||
setApiError('');
|
||||
try {
|
||||
const value = await api.auditLogs(params);
|
||||
setLogRows((value.items || []).map(normalizeAuditLog));
|
||||
} catch (error) {
|
||||
setApiError(explainApiError(error));
|
||||
} finally {
|
||||
setApiLoading(false);
|
||||
}
|
||||
};
|
||||
const refreshActiveCalls = useCallback(async ({ silent = false } = {}) => {
|
||||
setActiveCallsLoading(true);
|
||||
if (!silent) setActiveCallsBlockingLoading(true);
|
||||
setActiveCallsError('');
|
||||
try {
|
||||
const response = await api.activeCalls();
|
||||
setActiveCalls(response.items || []);
|
||||
setActiveCallsUpdatedAt(new Date());
|
||||
} catch (error) {
|
||||
setActiveCallsError(explainApiError(error));
|
||||
} finally {
|
||||
setActiveCallsLoading(false);
|
||||
if (!silent) setActiveCallsBlockingLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
@@ -305,7 +354,7 @@ export default function App() {
|
||||
.then((response) => {
|
||||
if (!mounted) return;
|
||||
setAuthUser(response.user);
|
||||
void refreshApi(response.user);
|
||||
void loadPageData('dashboard', response.user, { force: true });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!mounted) return;
|
||||
@@ -343,7 +392,7 @@ export default function App() {
|
||||
if (firstAllowed) {
|
||||
setActive(firstAllowed.key);
|
||||
}
|
||||
await refreshApi(response.user);
|
||||
await loadPageData(firstAllowed?.key || 'dashboard', response.user, { force: true });
|
||||
};
|
||||
const handleLogout = async () => {
|
||||
await api.logout();
|
||||
@@ -363,10 +412,12 @@ export default function App() {
|
||||
setDashboardTrends(null);
|
||||
setActiveCalls([]);
|
||||
setActiveCallsError('');
|
||||
loadedPagesRef.current.clear();
|
||||
pageRequestRef.current.controller?.abort('LOGOUT');
|
||||
};
|
||||
const reloadAfterMutation = async (operation) => {
|
||||
await operation();
|
||||
await refreshApi();
|
||||
await loadPageData(active, authUser, { force: true });
|
||||
};
|
||||
const applyRechargeResult = (response) => {
|
||||
const normalized = normalizeRecharge(response);
|
||||
@@ -458,7 +509,7 @@ export default function App() {
|
||||
: active === 'rechargeRecords'
|
||||
? { rechargeRows, apiLoading, apiError, refreshApi }
|
||||
: active === 'dashboard'
|
||||
? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }
|
||||
? { dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi }
|
||||
: active === 'businessPrefixes'
|
||||
? { can: canPermission }
|
||||
: active === 'numberLibrary'
|
||||
@@ -467,7 +518,9 @@ export default function App() {
|
||||
? {
|
||||
activeCalls,
|
||||
activeCallsLoading,
|
||||
activeCallsBlockingLoading,
|
||||
activeCallsError,
|
||||
activeCallsUpdatedAt,
|
||||
refreshActiveCalls,
|
||||
can: canPermission,
|
||||
onHangupActiveCall: async (id) => {
|
||||
@@ -478,13 +531,18 @@ export default function App() {
|
||||
: active === 'cdr'
|
||||
? { customerGatewayRows, vendorGatewayRows, can: canPermission }
|
||||
: active === 'quality'
|
||||
? { customerRows, lineGroupRows: landingLineGroupRows, can: canPermission }
|
||||
? {
|
||||
customerRows,
|
||||
lineGroupRows: landingLineGroupRows,
|
||||
can: canPermission,
|
||||
loadRuleDependencies: () => loadPageData('qualityRuleDependencies', authUser),
|
||||
}
|
||||
: active === 'users'
|
||||
? { userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteUser: (id) => reloadAfterMutation(() => api.deleteUser(id)) }
|
||||
: active === 'roles'
|
||||
? { roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can: canPermission, onDeleteRole: (id) => reloadAfterMutation(() => api.deleteRole(id)) }
|
||||
: active === 'operationLogs'
|
||||
? { logRows, apiLoading, apiError, refreshApi }
|
||||
? { logRows, apiLoading, apiError, refreshApi: refreshAuditLogs }
|
||||
: {};
|
||||
|
||||
if (!authUser) {
|
||||
@@ -527,7 +585,7 @@ export default function App() {
|
||||
<div className="nav-group" key={group.title}>
|
||||
<p>{group.title}</p>
|
||||
{group.items.filter((item) => !item.pending).map((item) => (
|
||||
<button key={item.key} className={active === item.key ? 'active' : ''} onClick={() => setActive(item.key)} title={`${item.label}${item.pending ? '(待设计)' : ''}`}>
|
||||
<button key={item.key} className={active === item.key ? 'active' : ''} onClick={() => { setActive(item.key); void loadPageData(item.key); }} title={`${item.label}${item.pending ? '(待设计)' : ''}`}>
|
||||
<span className="nav-short" aria-hidden="true">{item.label.slice(0, 1)}</span>
|
||||
<span className="nav-label">{item.label}</span>
|
||||
{item.pending ? <span className="nav-status">待设计</span> : null}
|
||||
@@ -553,6 +611,7 @@ export default function App() {
|
||||
<main className="page-content">
|
||||
{canAccessPage(active) ? <ActivePage {...activePageProps} /> : <NoPermissionPage label={activeLabel} />}
|
||||
</main>
|
||||
<PageLoadingDialog loading={apiLoading && active !== 'activeCalls'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+51
-20
@@ -22,6 +22,36 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
async function fetchWithTimeout(path, options = {}) {
|
||||
const { timeoutMs = DEFAULT_TIMEOUT_MS, signal: externalSignal, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
const abortFromExternal = () => controller.abort(externalSignal?.reason);
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
abortFromExternal();
|
||||
} else {
|
||||
externalSignal.addEventListener('abort', abortFromExternal, { once: true });
|
||||
}
|
||||
}
|
||||
const timeout = window.setTimeout(() => controller.abort('REQUEST_TIMEOUT'), timeoutMs);
|
||||
try {
|
||||
return await fetch(`${API_BASE}${path}`, {
|
||||
...fetchOptions,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted && !externalSignal?.aborted) {
|
||||
throw new ApiError('请求超过 10 秒未响应,请稍后重试。', { status: 408, code: 'REQUEST_TIMEOUT' });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
externalSignal?.removeEventListener('abort', abortFromExternal);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const token = window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const headers = new Headers(options.headers || {});
|
||||
@@ -33,7 +63,7 @@ async function request(path, options = {}) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
@@ -58,7 +88,7 @@ async function requestBlob(path, options = {}) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
const response = await fetchWithTimeout(path, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
@@ -116,13 +146,14 @@ export const api = {
|
||||
setAccessToken('');
|
||||
}
|
||||
},
|
||||
dashboardSummary: () => request('/dashboard/summary'),
|
||||
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }) => request(`/dashboard/trends?${new URLSearchParams(params)}`),
|
||||
activeCalls: () => request('/active-calls'),
|
||||
dashboardSummary: (options) => request('/dashboard/summary', options),
|
||||
dashboardTrends: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/trends?${new URLSearchParams(params)}`, options),
|
||||
dashboardOverview: (params = { hours: 24, bucketMinutes: 60 }, options) => request(`/dashboard/overview?${new URLSearchParams(params)}`, options),
|
||||
activeCalls: (options) => request('/active-calls', options),
|
||||
hangupActiveCall: (id) => request(`/active-calls/${encodeURIComponent(id)}/hangup`, { method: 'POST' }),
|
||||
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 100, ...params })}`),
|
||||
cdrs: (params = {}) => request(`/cdrs${queryString({ take: 25, ...params })}`),
|
||||
cdrDetail: (id) => request(`/cdrs/${encodeURIComponent(id)}`),
|
||||
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 100, ...params })}`),
|
||||
recordings: (params = {}) => request(`/recordings${queryString({ status: 'READY', limit: 50, ...params })}`),
|
||||
recordingDetail: (id) => request(`/recordings/${encodeURIComponent(id)}`),
|
||||
recordingPlayback: (id) => requestBlob(`/recordings/${encodeURIComponent(id)}/play`),
|
||||
saveRecordingReview: (id, body) => request(`/recordings/${encodeURIComponent(id)}/review`, { method: 'PUT', body: jsonBody(body) }),
|
||||
@@ -132,7 +163,7 @@ export const api = {
|
||||
enableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteQualityRule: (id) => request(`/quality/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
customers: () => request('/customers'),
|
||||
customers: (options) => request('/customers', options),
|
||||
createCustomer: (body) => request('/customers', { method: 'POST', body: jsonBody(body) }),
|
||||
updateCustomer: (id, body) => request(`/customers/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableCustomer: (id) => request(`/customers/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
@@ -143,7 +174,7 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('customer-recharge') }),
|
||||
}),
|
||||
vendors: () => request('/vendors'),
|
||||
vendors: (options) => request('/vendors', options),
|
||||
createVendor: (body) => request('/vendors', { method: 'POST', body: jsonBody(body) }),
|
||||
updateVendor: (id, body) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
deleteVendor: (id) => request(`/vendors/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
@@ -152,39 +183,39 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: jsonBody({ ...body, idempotencyKey: idempotencyKey('vendor-recharge') }),
|
||||
}),
|
||||
customerGateways: () => request('/customer-gateways'),
|
||||
customerGateways: (options) => request('/customer-gateways', options),
|
||||
createCustomerGateway: (body) => request('/customer-gateways', { method: 'POST', body: jsonBody(body) }),
|
||||
updateCustomerGateway: (id, body) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteCustomerGateway: (id) => request(`/customer-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
vendorGateways: () => request('/vendor-gateways'),
|
||||
vendorGateways: (options) => request('/vendor-gateways', options),
|
||||
createVendorGateway: (body) => request('/vendor-gateways', { method: 'POST', body: jsonBody(body) }),
|
||||
updateVendorGateway: (id, body) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteVendorGateway: (id) => request(`/vendor-gateways/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
landingLineGroups: () => request('/landing-line-groups'),
|
||||
landingLineGroups: (options) => request('/landing-line-groups', options),
|
||||
deleteLandingLineGroup: (id) => request(`/landing-line-groups/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
recharges: () => request('/recharges?take=100'),
|
||||
users: () => request('/users'),
|
||||
recharges: (options) => request('/recharges?take=100', options),
|
||||
users: (options) => request('/users', options),
|
||||
deleteUser: (id) => request(`/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
roles: () => request('/roles'),
|
||||
roles: (options) => request('/roles', options),
|
||||
deleteRole: (id) => request(`/roles/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
auditLogs: () => request('/audit-logs?take=100'),
|
||||
auditLogs: (params = {}, options) => request(`/audit-logs${queryString({ take: 100, ...params })}`, options),
|
||||
businessPrefixes: (params = {}) => request(`/business-prefixes${queryString(params)}`),
|
||||
createBusinessPrefix: (body) => request('/business-prefixes', { method: 'POST', body: jsonBody(body) }),
|
||||
updateBusinessPrefix: (id, body) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'PATCH', body: jsonBody(body) }),
|
||||
enableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/enable`, { method: 'POST' }),
|
||||
disableBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}/disable`, { method: 'POST' }),
|
||||
deleteBusinessPrefix: (id) => request(`/business-prefixes/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 100, ...params })}`),
|
||||
numberLibraryCities: (params = {}) => request(`/number-library/cities${queryString({ take: 25, ...params })}`),
|
||||
importNumberLibraryCities: (items) => request('/number-library/cities/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 100, ...params })}`),
|
||||
numberLibraryPhoneSegments: (params = {}) => request(`/number-library/phone-segments${queryString({ take: 25, ...params })}`),
|
||||
importNumberLibraryPhoneSegments: (items) => request('/number-library/phone-segments/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 100, ...params })}`),
|
||||
numberLibraryAreaCodes: (params = {}) => request(`/number-library/area-codes${queryString({ take: 25, ...params })}`),
|
||||
importNumberLibraryAreaCodes: (items) => request('/number-library/area-codes/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 100, ...params })}`),
|
||||
numberLibraryCarrierPrefixRules: (params = {}) => request(`/number-library/carrier-prefix-rules${queryString({ take: 25, ...params })}`),
|
||||
importNumberLibraryCarrierPrefixRules: (items) => request('/number-library/carrier-prefix-rules/import', { method: 'POST', body: jsonBody({ items }) }),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Alert, Badge, Button } from './ui.jsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input } from './ui.jsx';
|
||||
|
||||
const selectedBlue = '#2563EB';
|
||||
|
||||
@@ -41,6 +42,36 @@ export function Toolbar({ children }) {
|
||||
return <div className="toolbar">{children}</div>;
|
||||
}
|
||||
|
||||
export function DateRangeFields({ startDate, endDate, onStartChange, onEndChange }) {
|
||||
return (
|
||||
<>
|
||||
<Field label="开始日期"><Input type="date" value={startDate} max={endDate || undefined} onChange={(event) => onStartChange(event.target.value)} /></Field>
|
||||
<Field label="结束日期"><Input type="date" value={endDate} min={startDate || undefined} onChange={(event) => onEndChange(event.target.value)} /></Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pagination({ total, take, skip, count, loading, onPageChange, onPageSizeChange }) {
|
||||
const start = total ? skip + 1 : 0;
|
||||
const end = Math.min(total, skip + count);
|
||||
return (
|
||||
<div className="pagination-bar" aria-label="分页">
|
||||
<span>显示 {start}-{end} 条,共 {total} 条</span>
|
||||
<div className="pagination-actions">
|
||||
<label>每页
|
||||
<select value={take} disabled={loading} onChange={(event) => onPageSizeChange(Number(event.target.value))}>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button size="sm" variant="outline" disabled={loading || skip === 0} onClick={() => onPageChange(Math.max(0, skip - take))}>上一页</Button>
|
||||
<Button size="sm" variant="outline" disabled={loading || skip + count >= total} onClick={() => onPageChange(skip + take)}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({ title, aside, children, className = '' }) {
|
||||
return (
|
||||
<section className={`prototype-panel ${className}`}>
|
||||
@@ -55,7 +86,7 @@ export function Panel({ title, aside, children, className = '' }) {
|
||||
|
||||
export function ApiNotice({ loading, error, onRetry }) {
|
||||
if (loading) {
|
||||
return <Alert title="正在读取真实 API">正在从 LisgloSIPS API 拉取页面数据。</Alert>;
|
||||
return null;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
@@ -68,6 +99,32 @@ export function ApiNotice({ loading, error, onRetry }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PageLoadingDialog({ loading, message = '页面加载中,请稍候…', delay = 2000 }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
setVisible(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setTimeout(() => setVisible(true), delay);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [delay, loading]);
|
||||
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="page-loading-backdrop" role="status" aria-live="polite" aria-label={message}>
|
||||
<div className="page-loading-dialog">
|
||||
<span className="page-loading-spinner" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{message}</strong>
|
||||
<span>正在读取真实业务数据,请不要重复刷新。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ title = '暂无数据', children = '当前筛选条件下没有可展示的数据。' }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
@@ -139,10 +196,10 @@ export function StatusBadge({ children }) {
|
||||
return <Badge tone={toneForStatus(children)}>{children}</Badge>;
|
||||
}
|
||||
|
||||
export function SimpleTable({ columns, rows, onRowClick, selectedKey }) {
|
||||
export function SimpleTable({ columns, rows, onRowClick, selectedKey, loading = false, emptyTitle = '暂无数据', emptyDescription }) {
|
||||
return (
|
||||
<div className="table-shell">
|
||||
<table className="proto-table">
|
||||
<table className="proto-table" aria-busy={loading}>
|
||||
{columns.some((column) => column.width) ? (
|
||||
<colgroup>
|
||||
{columns.map((column) => <col key={column.key} style={column.width ? { width: column.width } : undefined} />)}
|
||||
@@ -154,10 +211,14 @@ export function SimpleTable({ columns, rows, onRowClick, selectedKey }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
{loading && rows.length === 0 ? Array.from({ length: 4 }, (_, rowIndex) => (
|
||||
<tr className="table-skeleton-row" key={`skeleton-${rowIndex}`} aria-hidden="true">
|
||||
{columns.map((column) => <td key={column.key}><span className="table-skeleton-cell" /></td>)}
|
||||
</tr>
|
||||
)) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length}>
|
||||
<EmptyState />
|
||||
<EmptyState title={emptyTitle}>{emptyDescription}</EmptyState>
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.map((row, index) => (
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, PageLoadingDialog, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { formatDateTime, formatDurationText } from '../utils/formatters.js';
|
||||
|
||||
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
|
||||
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsBlockingLoading, activeCallsError, activeCallsUpdatedAt, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
|
||||
const [busyId, setBusyId] = useState('');
|
||||
const [hangupTarget, setHangupTarget] = useState(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
@@ -36,32 +36,36 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
|
||||
}
|
||||
const timer = window.setInterval(() => {
|
||||
if (!activeCallsLoading) {
|
||||
void refreshActiveCalls();
|
||||
void refreshActiveCalls({ silent: true });
|
||||
}
|
||||
}, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [autoRefresh, activeCallsLoading, refreshActiveCalls]);
|
||||
useEffect(() => {
|
||||
if (activeCallsError) setAutoRefresh(false);
|
||||
}, [activeCallsError]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
title="当前通话"
|
||||
desc="查看 OpenSIPS 当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
|
||||
desc="查看交换机当前已跟踪的实时呼叫,并对异常通话执行强制挂断。"
|
||||
actions={(
|
||||
<div className="table-actions">
|
||||
<Button variant={autoRefresh ? 'secondary' : 'outline'} onClick={() => setAutoRefresh((value) => !value)}>
|
||||
{autoRefresh ? '自动刷新中' : '开启自动刷新'}
|
||||
</Button>
|
||||
<Button icon={<Icon type="reload" />} onClick={refreshActiveCalls} disabled={activeCallsLoading}>刷新通话</Button>
|
||||
<Button icon={<Icon type="reload" />} onClick={() => refreshActiveCalls()} disabled={activeCallsLoading}>刷新通话</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<ApiNotice loading={activeCallsLoading} error={activeCallsError} onRetry={refreshActiveCalls} />
|
||||
<ApiNotice loading={activeCallsLoading} error={activeCallsError} onRetry={() => refreshActiveCalls()} />
|
||||
<PageLoadingDialog loading={activeCallsBlockingLoading} />
|
||||
<section className="metric-grid active-call-metrics">
|
||||
<div className="metric-card">
|
||||
<span>当前通话数</span>
|
||||
<strong>{rows.length}</strong>
|
||||
<em className="metric-neutral">OpenSIPS MI</em>
|
||||
<em className="metric-neutral">实时交换机数据</em>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span>最长通话</span>
|
||||
@@ -71,11 +75,11 @@ export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsEr
|
||||
<div className="metric-card">
|
||||
<span>控制面</span>
|
||||
<strong>{activeCallsError ? '异常' : '就绪'}</strong>
|
||||
<em className={activeCallsError ? 'metric-warn' : 'metric-neutral'}>MI 受控访问</em>
|
||||
<em className={activeCallsError ? 'metric-warn' : 'metric-neutral'}>{activeCallsUpdatedAt ? `更新于 ${activeCallsUpdatedAt.toLocaleTimeString('zh-CN', { hour12: false })}` : '受控接口访问'}</em>
|
||||
</div>
|
||||
</section>
|
||||
<Panel title="实时呼叫列表" aside={<Badge tone={rows.length ? 'success' : 'neutral'}>{rows.length} 路</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={rows} columns={[
|
||||
<SimpleTable loading={activeCallsBlockingLoading} rows={rows} columns={[
|
||||
{ key: 'callId', label: 'Call-ID' },
|
||||
{ key: 'callerText', label: '主叫' },
|
||||
{ key: 'calleeText', label: '被叫' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { enStatus, formatDate, zhStatus } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
@@ -145,6 +145,7 @@ export function BusinessPrefixesPage({ can = () => true }) {
|
||||
actions={canManage ? <Button icon={<Icon type="plus" />} onClick={openCreate}>新增业务前缀</Button> : null}
|
||||
/>
|
||||
<ApiNotice loading={loading} error={error} onRetry={loadPrefixes} />
|
||||
<PageLoadingDialog loading={loading} />
|
||||
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
||||
<Panel
|
||||
title="业务前缀"
|
||||
@@ -164,7 +165,7 @@ export function BusinessPrefixesPage({ can = () => true }) {
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} onClick={loadPrefixes}>查询</Button>
|
||||
</Toolbar>
|
||||
<SimpleTable rows={rows} columns={[
|
||||
<SimpleTable loading={loading} rows={rows} columns={[
|
||||
{ key: 'prefix', label: '业务前缀', width: '120px' },
|
||||
{ key: 'name', label: '名称', width: '160px' },
|
||||
{ key: 'priority', label: '优先级', width: '90px' },
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
|
||||
import { formatCurrency, formatDateTime, formatDurationText, zhStatus, carrierLabel } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
|
||||
|
||||
const defaultRange = lastSevenDays();
|
||||
|
||||
export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can = () => true }) {
|
||||
const [detailCdr, setDetailCdr] = useState(null);
|
||||
const [cdrRows, setCdrRows] = useState([]);
|
||||
const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 50, skip: 0, hasMore: false });
|
||||
const [cdrMeta, setCdrMeta] = useState({ total: 0, take: 25, skip: 0, hasMore: false });
|
||||
const [cdrLoading, setCdrLoading] = useState(false);
|
||||
const [cdrError, setCdrError] = useState('');
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
@@ -24,9 +27,9 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
vendorGatewayId: 'all',
|
||||
cityCode: '',
|
||||
carrier: 'all',
|
||||
startedFrom: '',
|
||||
startedTo: '',
|
||||
take: '50',
|
||||
startedFrom: defaultRange.startDate,
|
||||
startedTo: defaultRange.endDate,
|
||||
take: '25',
|
||||
skip: 0,
|
||||
});
|
||||
const money = (value) => (value === null || value === undefined ? '¥0.000000' : formatCurrency(value, 6));
|
||||
@@ -100,8 +103,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
vendorGatewayId: nextFilters.vendorGatewayId,
|
||||
cityCode: nextFilters.cityCode.trim(),
|
||||
carrier: nextFilters.carrier,
|
||||
startedFrom: nextFilters.startedFrom ? new Date(nextFilters.startedFrom).toISOString() : '',
|
||||
startedTo: nextFilters.startedTo ? new Date(nextFilters.startedTo).toISOString() : '',
|
||||
...dateRangeParams(nextFilters.startedFrom, nextFilters.startedTo),
|
||||
take: nextFilters.take,
|
||||
skip: String(nextFilters.skip),
|
||||
});
|
||||
@@ -133,12 +135,12 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
void loadCdrs(nextFilters);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: '', startedTo: '', take: filters.take, skip: 0 };
|
||||
const nextFilters = { caller: '', callee: '', customerGatewayId: 'all', vendorGatewayId: 'all', cityCode: '', carrier: 'all', startedFrom: defaultRange.startDate, startedTo: defaultRange.endDate, take: filters.take, skip: 0 };
|
||||
setFilters(nextFilters);
|
||||
void loadCdrs(nextFilters);
|
||||
};
|
||||
const changePage = (direction) => {
|
||||
const take = Number(filters.take) || 50;
|
||||
const take = Number(filters.take) || 25;
|
||||
const nextSkip = Math.max(0, filters.skip + direction * take);
|
||||
const nextFilters = { ...filters, skip: nextSkip };
|
||||
setFilters(nextFilters);
|
||||
@@ -202,6 +204,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
actions={<Button variant="secondary" icon={<Icon type="export" />} disabled>导出 CSV</Button>}
|
||||
/>
|
||||
<ApiNotice loading={cdrLoading} error={cdrError} onRetry={() => void loadCdrs()} />
|
||||
<PageLoadingDialog loading={cdrLoading} />
|
||||
<Toolbar>
|
||||
<Field label="主叫号码"><Input value={filters.caller} onChange={(event) => updateFilter('caller', event.target.value)} placeholder="输入主叫号码" /></Field>
|
||||
<Field label="被叫号码"><Input value={filters.callee} onChange={(event) => updateFilter('callee', event.target.value)} placeholder="输入被叫号码" /></Field>
|
||||
@@ -229,8 +232,7 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
<option value="UNKNOWN">未知</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="开始时间"><Input type="datetime-local" value={filters.startedFrom} onChange={(event) => updateFilter('startedFrom', event.target.value)} /></Field>
|
||||
<Field label="结束时间"><Input type="datetime-local" value={filters.startedTo} onChange={(event) => updateFilter('startedTo', event.target.value)} /></Field>
|
||||
<DateRangeFields startDate={filters.startedFrom} endDate={filters.startedTo} onStartChange={(value) => updateFilter('startedFrom', value)} onEndChange={(value) => updateFilter('startedTo', value)} />
|
||||
<Field label="每页">
|
||||
<Select value={filters.take} onChange={(event) => {
|
||||
const nextFilters = { ...filters, take: event.target.value, skip: 0 };
|
||||
@@ -247,19 +249,14 @@ export function CdrPage({ customerGatewayRows = [], vendorGatewayRows = [], can
|
||||
<Button variant="outline" disabled={cdrLoading} onClick={resetFilters}>重置</Button>
|
||||
</Toolbar>
|
||||
<Panel title="话单列表" className="wide-panel" aside={<Badge tone="info">{cdrMeta.total} 条</Badge>}>
|
||||
<SimpleTable rows={cdrRows} columns={[
|
||||
<SimpleTable loading={cdrLoading} rows={cdrRows} columns={[
|
||||
{ key: 'caller', label: '主叫号码' },
|
||||
{ key: 'callee', label: '被叫号码' },
|
||||
{ key: 'location', label: '地级市' },
|
||||
{ key: 'operatorText', label: '运营商' },
|
||||
{ key: 'customerGatewayName', label: '客户网关名称' },
|
||||
{ key: 'callIp', label: '呼叫IP地址' },
|
||||
{ key: 'vendorGatewayName', label: '落地网关名称' },
|
||||
{ key: 'lineIp', label: '线路IP地址' },
|
||||
{ key: 'callTime', label: '呼叫时间' },
|
||||
{ key: 'durationText', label: '通话时长' },
|
||||
{ key: 'ratingStatusText', label: '计费状态', status: true },
|
||||
{ key: 'callIp', label: '呼叫 IP' },
|
||||
{ key: 'lineIp', label: '线路 IP' },
|
||||
{ key: 'customerFee', label: '客户费用' },
|
||||
{ key: 'recordingText', label: '录音', status: true },
|
||||
{ key: 'callTime', label: '呼叫时间' },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
|
||||
@@ -296,7 +296,7 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
|
||||
{localError ? <Alert title="客户网关操作失败" tone="danger">{localError}</Alert> : null}
|
||||
<section className="content-grid">
|
||||
<Panel title="客户网关列表" className="wide-panel">
|
||||
<SimpleTable rows={gatewayRows} columns={[
|
||||
<SimpleTable loading={apiLoading} rows={gatewayRows} columns={[
|
||||
{ key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' },
|
||||
{ key: 'name', label: '名称', width: '240px', className: 'table-cell-compact' },
|
||||
{ key: 'customer', label: '客户', width: '220px', className: 'table-cell-compact' },
|
||||
|
||||
@@ -157,6 +157,7 @@ export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord
|
||||
<section className="master-detail">
|
||||
<Panel title="客户列表" className="main-list wide-panel">
|
||||
<SimpleTable
|
||||
loading={apiLoading}
|
||||
rows={customerRows}
|
||||
columns={[
|
||||
{ key: 'id', label: '客户 ID', width: '108px', className: 'table-cell-compact' },
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { Badge, Button } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, EmptyState, MiniBarChart, LineChart } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Panel, ApiNotice, MiniBarChart, LineChart } from '../components/layout.jsx';
|
||||
import { formatCurrency } from '../utils/formatters.js';
|
||||
import { metrics, callTrend, answerTrend } from '../fixtures/devFixtures.js';
|
||||
|
||||
const pendingMetrics = [
|
||||
'今日通话数', '当前在线通话', '今日接通率', '客户消费', '供应商成本', '今日毛利',
|
||||
'在线注册用户', '活跃客户', '活跃落地网关', '异常网关', '质检待处理',
|
||||
].map((label) => ({ label, value: '--', delta: '等待真实数据', tone: 'neutral' }));
|
||||
|
||||
const realtimeSourceLabel = (source) => source === 'not_configured' ? '实时数据暂未接入' : source || '实时数据';
|
||||
|
||||
function dashboardMetrics(summary) {
|
||||
if (!summary) {
|
||||
return metrics;
|
||||
return pendingMetrics;
|
||||
}
|
||||
return [
|
||||
{ label: '今日通话数', value: String(summary.calls.totalCalls), delta: '真实 API', tone: 'neutral' },
|
||||
{ label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: summary.realtime.source, tone: 'neutral' },
|
||||
{ label: '当前在线通话', value: String(summary.realtime.onlineCalls), delta: realtimeSourceLabel(summary.realtime.source), tone: 'neutral' },
|
||||
{ label: '今日接通率', value: `${(Number(summary.calls.answerRate) * 100).toFixed(2)}%`, delta: `${summary.calls.answeredCalls}/${summary.calls.totalCalls}`, tone: 'neutral' },
|
||||
{ label: '客户消费', value: formatCurrency(summary.money.customerFee), delta: '今日', tone: 'neutral' },
|
||||
{ label: '供应商成本', value: formatCurrency(summary.money.vendorCost), delta: '今日', tone: 'neutral' },
|
||||
{ label: '今日毛利', value: formatCurrency(summary.money.grossProfit), delta: '今日', tone: 'neutral' },
|
||||
{ label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: summary.realtime.source, tone: 'neutral' },
|
||||
{ label: '在线注册用户', value: String(summary.realtime.registeredUsers), delta: realtimeSourceLabel(summary.realtime.source), tone: 'neutral' },
|
||||
{ label: '活跃客户', value: String(summary.entities.activeCustomers), delta: '启用', tone: 'neutral' },
|
||||
{ label: '活跃落地网关', value: String(summary.entities.activeVendorGateways), delta: '启用', tone: 'neutral' },
|
||||
{ label: '异常网关', value: String(summary.abnormalGateways.length), delta: '失败 Top', tone: summary.abnormalGateways.length ? 'warn' : 'neutral' },
|
||||
@@ -22,11 +28,10 @@ function dashboardMetrics(summary) {
|
||||
];
|
||||
}
|
||||
|
||||
export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi, customerRows }) {
|
||||
export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, apiError, refreshApi }) {
|
||||
const trendBuckets = dashboardTrends?.buckets || [];
|
||||
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : callTrend;
|
||||
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : answerTrend;
|
||||
const failureCodes = dashboardSummary?.failureCodes?.length ? dashboardSummary.failureCodes : [];
|
||||
const callTrendData = trendBuckets.length ? trendBuckets.map((bucket) => bucket.calls.totalCalls) : [0];
|
||||
const answerTrendData = trendBuckets.length ? trendBuckets.map((bucket) => Math.round(Number(bucket.calls.answerRate) * 100)) : [0, 0];
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
@@ -51,22 +56,7 @@ export function DashboardPage({ dashboardSummary, dashboardTrends, apiLoading, a
|
||||
<Panel title="最近 24 小时接通率趋势" aside={<Badge tone="info">dialog statistics</Badge>}>
|
||||
<LineChart data={answerTrendData.length ? answerTrendData : [0, 0]} />
|
||||
</Panel>
|
||||
<Panel title="客户消费 TOP 10">
|
||||
<div className="rank-list">
|
||||
{customerRows.length ? customerRows.slice(0, 3).map((item, index) => (
|
||||
<div key={item.id}><span>{index + 1}</span><strong>{item.name}</strong><em>{item.balance}</em></div>
|
||||
)) : <EmptyState title="暂无客户数据">客户 API 返回空列表。</EmptyState>}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="失败响应码分布">
|
||||
<div className="code-grid">
|
||||
{failureCodes.length ? failureCodes.map((code) => (
|
||||
<div key={code.sipCode}><strong>{code.count}</strong><span>{code.sipCode}</span></div>
|
||||
)) : <EmptyState title="暂无失败码">今日没有失败 CDR 或 API 尚未返回数据。</EmptyState>}
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Field, Input, Select, Tabs, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, SimpleTable } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, SimpleTable, Pagination } from '../components/layout.jsx';
|
||||
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
@@ -53,10 +53,10 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
const [rows, setRows] = useState(emptyNumberLibraryRows);
|
||||
const [totals, setTotals] = useState(emptyNumberLibraryTotals);
|
||||
const [filters, setFilters] = useState({
|
||||
cities: { keyword: '' },
|
||||
phoneSegments: { segment7: '', cityCode: '', carrier: 'all' },
|
||||
areaCodes: { areaCode: '', cityCode: '' },
|
||||
carrierPrefixRules: { prefix: '', carrier: 'all' },
|
||||
cities: { keyword: '', take: 25, skip: 0 },
|
||||
phoneSegments: { segment7: '', cityCode: '', carrier: 'all', take: 25, skip: 0 },
|
||||
areaCodes: { areaCode: '', cityCode: '', take: 25, skip: 0 },
|
||||
carrierPrefixRules: { prefix: '', carrier: 'all', take: 25, skip: 0 },
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -66,8 +66,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
const [importing, setImporting] = useState(false);
|
||||
const canManage = can('number_library.manage');
|
||||
|
||||
const readTab = async (tab) => {
|
||||
const params = filters[tab] || {};
|
||||
const readTab = async (tab, params = filters[tab] || {}) => {
|
||||
if (tab === 'cities') {
|
||||
const payload = await api.numberLibraryCities(params);
|
||||
return normalizeNumberLibraryList(payload, (item) => ({
|
||||
@@ -120,11 +119,11 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
}));
|
||||
};
|
||||
|
||||
const loadTab = async (tab = activeTab) => {
|
||||
const loadTab = async (tab = activeTab, params = filters[tab]) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await readTab(tab);
|
||||
const result = await readTab(tab, params);
|
||||
setRows((current) => ({ ...current, [tab]: result.rows }));
|
||||
setTotals((current) => ({ ...current, [tab]: result.total }));
|
||||
} catch (loadError) {
|
||||
@@ -136,18 +135,27 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
|
||||
useEffect(() => {
|
||||
void loadTab('cities');
|
||||
void loadTab('phoneSegments');
|
||||
void loadTab('areaCodes');
|
||||
void loadTab('carrierPrefixRules');
|
||||
}, []);
|
||||
|
||||
const updateFilter = (key, value) => {
|
||||
setFilters((current) => ({
|
||||
...current,
|
||||
[activeTab]: { ...current[activeTab], [key]: value },
|
||||
[activeTab]: { ...current[activeTab], [key]: value, skip: 0 },
|
||||
}));
|
||||
};
|
||||
|
||||
const searchActiveTab = () => {
|
||||
const next = { ...filters[activeTab], skip: 0 };
|
||||
setFilters((current) => ({ ...current, [activeTab]: next }));
|
||||
void loadTab(activeTab, next);
|
||||
};
|
||||
|
||||
const changePage = (skip, take = filters[activeTab].take) => {
|
||||
const next = { ...filters[activeTab], skip, take };
|
||||
setFilters((current) => ({ ...current, [activeTab]: next }));
|
||||
void loadTab(activeTab, next);
|
||||
};
|
||||
|
||||
const openImport = (tab) => {
|
||||
setImportTarget(tab);
|
||||
setImportText(JSON.stringify(numberLibraryImportExamples[tab], null, 2));
|
||||
@@ -190,7 +198,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
return (
|
||||
<>
|
||||
<Field label="省份/城市"><Input value={current.keyword} onChange={(event) => updateFilter('keyword', event.target.value)} placeholder="输入省份或城市" /></Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -210,7 +218,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
<option value="UNKNOWN">未知</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -219,7 +227,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
<>
|
||||
<Field label="固话区号"><Input value={current.areaCode} onChange={(event) => updateFilter('areaCode', event.target.value)} placeholder="如 0551" /></Field>
|
||||
<Field label="地级市代码"><Input value={current.cityCode} onChange={(event) => updateFilter('cityCode', event.target.value)} placeholder="如 340100" /></Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -237,7 +245,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
<option value="UNKNOWN">未知</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} onClick={() => void loadTab()}>查询</Button>
|
||||
<Button icon={<Icon type="search" />} onClick={searchActiveTab}>查询</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -245,7 +253,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
const renderTable = (tab) => {
|
||||
if (tab === 'cities') {
|
||||
return (
|
||||
<SimpleTable rows={rows.cities} columns={[
|
||||
<SimpleTable loading={loading} rows={rows.cities} columns={[
|
||||
{ key: 'code', label: '地级市代码', width: '110px' },
|
||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||
{ key: 'cityName', label: '地级市', width: '140px' },
|
||||
@@ -258,7 +266,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
}
|
||||
if (tab === 'phoneSegments') {
|
||||
return (
|
||||
<SimpleTable rows={rows.phoneSegments} columns={[
|
||||
<SimpleTable loading={loading} rows={rows.phoneSegments} columns={[
|
||||
{ key: 'segment7', label: '前 7 位', width: '110px' },
|
||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
||||
@@ -272,7 +280,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
}
|
||||
if (tab === 'areaCodes') {
|
||||
return (
|
||||
<SimpleTable rows={rows.areaCodes} columns={[
|
||||
<SimpleTable loading={loading} rows={rows.areaCodes} columns={[
|
||||
{ key: 'areaCode', label: '区号', width: '100px' },
|
||||
{ key: 'provinceName', label: '省份', width: '120px' },
|
||||
{ key: 'cityCode', label: '地级市代码', width: '120px' },
|
||||
@@ -283,7 +291,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SimpleTable rows={rows.carrierPrefixRules} columns={[
|
||||
<SimpleTable loading={loading} rows={rows.carrierPrefixRules} columns={[
|
||||
{ key: 'prefix', label: '前缀', width: '100px' },
|
||||
{ key: 'carrier', label: '运营商', width: '110px' },
|
||||
{ key: 'priority', label: '优先级', width: '90px' },
|
||||
@@ -305,6 +313,7 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
actions={canManage ? <Button icon={<Icon type="export" />} onClick={() => openImport(activeTab)}>批量导入</Button> : null}
|
||||
/>
|
||||
<ApiNotice loading={loading} error={error} onRetry={() => void loadTab(activeTab)} />
|
||||
<PageLoadingDialog loading={loading} />
|
||||
{message ? <Alert title="操作完成" tone="success">{message}</Alert> : null}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
@@ -329,6 +338,17 @@ export function NumberLibraryPage({ can = () => true }) {
|
||||
</Toolbar>
|
||||
) : null}
|
||||
{renderTable(tab.value)}
|
||||
{tab.value === activeTab ? (
|
||||
<Pagination
|
||||
total={totals[tab.value]}
|
||||
take={filters[tab.value].take}
|
||||
skip={filters[tab.value].skip}
|
||||
count={rows[tab.value].length}
|
||||
loading={loading}
|
||||
onPageChange={(skip) => changePage(skip)}
|
||||
onPageSizeChange={(take) => changePage(0, take)}
|
||||
/>
|
||||
) : null}
|
||||
</Panel>
|
||||
),
|
||||
}))}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Drawer, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
|
||||
import { operationLogRows } from '../fixtures/devFixtures.js';
|
||||
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
|
||||
|
||||
const defaultRange = lastSevenDays();
|
||||
|
||||
function toneForStatus(status) {
|
||||
return status === '成功' || status === 'SUCCESS' ? 'success' : status === '失败' || status === 'FAILURE' ? 'danger' : 'neutral';
|
||||
@@ -11,8 +14,8 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [moduleFilter, setModuleFilter] = useState('all');
|
||||
const [resultFilter, setResultFilter] = useState('all');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [startDate, setStartDate] = useState(defaultRange.startDate);
|
||||
const [endDate, setEndDate] = useState(defaultRange.endDate);
|
||||
const [detailLog, setDetailLog] = useState(null);
|
||||
const modules = useMemo(() => Array.from(new Set(logRows.map((log) => log.module))), [logRows]);
|
||||
const visibleLogs = useMemo(() => {
|
||||
@@ -23,22 +26,38 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
|
||||
return matchesKeyword && (moduleFilter === 'all' || log.module === moduleFilter) && (resultFilter === 'all' || log.result === resultFilter) && (!startDate || logDate >= startDate) && (!endDate || logDate <= endDate);
|
||||
});
|
||||
}, [endDate, keyword, logRows, moduleFilter, resultFilter, startDate]);
|
||||
const resetFilters = () => { setKeyword(''); setModuleFilter('all'); setResultFilter('all'); setStartDate(''); setEndDate(''); };
|
||||
const queryLogs = (overrides = {}) => {
|
||||
const next = { moduleFilter, resultFilter, startDate, endDate, ...overrides };
|
||||
return refreshApi({
|
||||
module: next.moduleFilter,
|
||||
result: next.resultFilter === '成功' ? 'SUCCESS' : next.resultFilter === '失败' ? 'FAILURE' : 'all',
|
||||
...dateRangeParams(next.startDate, next.endDate, 'createdFrom', 'createdTo'),
|
||||
});
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setKeyword('');
|
||||
setModuleFilter('all');
|
||||
setResultFilter('all');
|
||||
setStartDate(defaultRange.startDate);
|
||||
setEndDate(defaultRange.endDate);
|
||||
void queryLogs({ moduleFilter: 'all', resultFilter: 'all', ...defaultRange });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title="操作日志" desc="审计登录、配置变更、敏感操作及其执行结果。" actions={<Button variant="secondary" icon={<Icon type="export" />}>导出日志</Button>} />
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={() => void queryLogs()} />
|
||||
<PageLoadingDialog loading={apiLoading} />
|
||||
<Toolbar>
|
||||
<Field label="关键词"><Input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="用户、操作对象或 IP" /></Field>
|
||||
<Field label="功能模块"><Select value={moduleFilter} onChange={(event) => setModuleFilter(event.target.value)}><option value="all">全部模块</option>{modules.map((module) => <option key={module} value={module}>{module}</option>)}</Select></Field>
|
||||
<Field label="执行结果"><Select value={resultFilter} onChange={(event) => setResultFilter(event.target.value)}><option value="all">全部结果</option><option value="成功">成功</option><option value="失败">失败</option></Select></Field>
|
||||
<Field label="开始日期"><Input type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></Field>
|
||||
<Field label="结束日期"><Input type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} /></Field>
|
||||
<DateRangeFields startDate={startDate} endDate={endDate} onStartChange={setStartDate} onEndChange={setEndDate} />
|
||||
<Button icon={<Icon type="search" />} disabled={apiLoading} onClick={() => void queryLogs()}>查询</Button>
|
||||
<Button variant="outline" onClick={resetFilters}>重置</Button>
|
||||
</Toolbar>
|
||||
<Panel title="日志列表" aside={<Badge tone="neutral">共 {visibleLogs.length} 条</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={visibleLogs} columns={[
|
||||
<SimpleTable loading={apiLoading} rows={visibleLogs} columns={[
|
||||
{ key: 'time', label: '操作时间' },
|
||||
{ key: 'user', label: '操作用户' },
|
||||
{ key: 'module', label: '功能模块' },
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue } from '../components/layout.jsx';
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, PageLoadingDialog, Modal, ConfirmDialog, Drawer, StatusBadge, SimpleTable, KeyValue, DateRangeFields } from '../components/layout.jsx';
|
||||
import { reviewResultValue, normalizeQualityRule, normalizeRecording } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
import { dateRangeParams, lastSevenDays } from '../utils/dateRange.js';
|
||||
|
||||
const emptySamplingRuleForm = { name: '', customerId: '', ratio: 5, lineGroupId: '', start: '', expiresAt: '', status: '启用' };
|
||||
const defaultRange = lastSevenDays();
|
||||
|
||||
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true }) {
|
||||
export function QualityPage({ customerRows = [], lineGroupRows = [], can = () => true, loadRuleDependencies }) {
|
||||
const [ruleRows, setRuleRows] = useState([]);
|
||||
const [recordingRows, setRecordingRows] = useState([]);
|
||||
const [qualityLoading, setQualityLoading] = useState(false);
|
||||
const [qualityError, setQualityError] = useState('');
|
||||
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '100' });
|
||||
const [recordingFilter, setRecordingFilter] = useState({ reviewStatus: 'all', limit: '50', ...defaultRange });
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState(undefined);
|
||||
const [ruleForm, setRuleForm] = useState(emptySamplingRuleForm);
|
||||
const [deleteRuleTarget, setDeleteRuleTarget] = useState(null);
|
||||
const [ruleBusy, setRuleBusy] = useState(false);
|
||||
const [rulesLoading, setRulesLoading] = useState(false);
|
||||
const [ruleError, setRuleError] = useState('');
|
||||
const [detailRecording, setDetailRecording] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
@@ -50,19 +53,33 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
const params = {
|
||||
limit: nextFilter.limit,
|
||||
reviewStatus: nextFilter.reviewStatus,
|
||||
...dateRangeParams(nextFilter.startDate, nextFilter.endDate),
|
||||
};
|
||||
const [recordingList, ruleList] = await Promise.all([
|
||||
api.recordings(params),
|
||||
api.qualityRules(),
|
||||
]);
|
||||
const recordingList = await api.recordings(params);
|
||||
setRecordingRows((recordingList || []).map(normalizeRecording));
|
||||
setRuleRows((ruleList || []).map(normalizeQualityRule));
|
||||
} catch (error) {
|
||||
setQualityError(explainApiError(error));
|
||||
} finally {
|
||||
setQualityLoading(false);
|
||||
}
|
||||
};
|
||||
const refreshRules = async () => {
|
||||
setRulesLoading(true);
|
||||
setRuleError('');
|
||||
try {
|
||||
const ruleList = await api.qualityRules();
|
||||
setRuleRows((ruleList || []).map(normalizeQualityRule));
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
} finally {
|
||||
setRulesLoading(false);
|
||||
}
|
||||
};
|
||||
const openRulesDrawer = () => {
|
||||
setShowRules(true);
|
||||
if (!ruleRows.length) void refreshRules();
|
||||
if (loadRuleDependencies) void loadRuleDependencies();
|
||||
};
|
||||
useEffect(() => {
|
||||
void refreshQuality();
|
||||
}, []);
|
||||
@@ -134,7 +151,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
} else {
|
||||
await api.createQualityRule(ruleBody());
|
||||
}
|
||||
await refreshQuality();
|
||||
await refreshRules();
|
||||
closeRuleModal();
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
@@ -151,7 +168,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
} else {
|
||||
await api.enableQualityRule(rule.id);
|
||||
}
|
||||
await refreshQuality();
|
||||
await refreshRules();
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
} finally {
|
||||
@@ -164,7 +181,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
setRuleError('');
|
||||
try {
|
||||
await api.deleteQualityRule(deleteRuleTarget.id);
|
||||
await refreshQuality();
|
||||
await refreshRules();
|
||||
setDeleteRuleTarget(null);
|
||||
} catch (error) {
|
||||
setRuleError(explainApiError(error));
|
||||
@@ -279,6 +296,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
const changeRecordingFilter = (key, value) => {
|
||||
const nextFilter = { ...recordingFilter, [key]: value };
|
||||
setRecordingFilter(nextFilter);
|
||||
};
|
||||
const resetRecordingFilter = () => {
|
||||
const nextFilter = { reviewStatus: 'all', limit: recordingFilter.limit, ...defaultRange };
|
||||
setRecordingFilter(nextFilter);
|
||||
void refreshQuality(nextFilter);
|
||||
};
|
||||
|
||||
@@ -287,9 +308,10 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
<PageTitle
|
||||
title="质检中心"
|
||||
desc="集中查看录音、完成试听、问题标注和人工质检评分。"
|
||||
actions={<div className="table-actions"><Button icon={<Icon type="reload" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>刷新</Button><Button variant="outline" onClick={() => setShowRules(true)}>抽检规则</Button></div>}
|
||||
actions={<div className="table-actions"><Button icon={<Icon type="reload" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>刷新</Button><Button variant="outline" onClick={openRulesDrawer}>抽检规则</Button></div>}
|
||||
/>
|
||||
<ApiNotice loading={qualityLoading} error={qualityError} onRetry={() => void refreshQuality()} />
|
||||
<PageLoadingDialog loading={qualityLoading} />
|
||||
<Toolbar>
|
||||
<Field label="质检状态">
|
||||
<Select value={recordingFilter.reviewStatus} onChange={(event) => changeRecordingFilter('reviewStatus', event.target.value)}>
|
||||
@@ -298,17 +320,19 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
<option value="REVIEWED">已完成</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<DateRangeFields startDate={recordingFilter.startDate} endDate={recordingFilter.endDate} onStartChange={(value) => changeRecordingFilter('startDate', value)} onEndChange={(value) => changeRecordingFilter('endDate', value)} />
|
||||
<Field label="读取条数">
|
||||
<Select value={recordingFilter.limit} onChange={(event) => changeRecordingFilter('limit', event.target.value)}>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button icon={<Icon type="search" />} disabled={qualityLoading} onClick={() => void refreshQuality()}>查询</Button>
|
||||
<Button variant="outline" disabled={qualityLoading} onClick={resetRecordingFilter}>重置</Button>
|
||||
</Toolbar>
|
||||
<Panel title="录音列表" aside={<Badge tone="neutral">共 {recordingRows.length} 条录音</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={recordingRows} columns={[
|
||||
<SimpleTable loading={qualityLoading} rows={recordingRows} columns={[
|
||||
{ key: 'callId', label: 'Call-ID' },
|
||||
{ key: 'customer', label: '客户' },
|
||||
{ key: 'caller', label: '主叫' },
|
||||
@@ -329,7 +353,7 @@ export function QualityPage({ customerRows = [], lineGroupRows = [], can = () =>
|
||||
<div><strong>规则管理</strong><span>按客户和线路设置录音抽检比例。</span></div>
|
||||
{canManageQuality ? <Button icon={<Icon type="plus" />} disabled={ruleBusy} onClick={openCreateRule}>新增抽检规则</Button> : null}
|
||||
</div>
|
||||
<SimpleTable rows={ruleRows} columns={[
|
||||
<SimpleTable loading={rulesLoading} rows={ruleRows} columns={[
|
||||
{ key: 'name', label: '规则名称' },
|
||||
{ key: 'customer', label: '客户' },
|
||||
{ key: 'ratio', label: '抽检比例', render: (row) => `${row.ratio}%` },
|
||||
|
||||
@@ -9,7 +9,7 @@ export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refres
|
||||
const recordTable = (
|
||||
<section className="content-grid">
|
||||
<Panel title={`${ownerLabel}充值记录列表`} className="wide-panel">
|
||||
<SimpleTable rows={visibleRows} columns={[
|
||||
<SimpleTable loading={apiLoading} rows={visibleRows} columns={[
|
||||
{ key: 'id', label: '记录 ID', width: '118px', className: 'table-cell-compact' },
|
||||
{ key: 'owner', label: ownerLabel, width: '160px', className: 'table-cell-compact' },
|
||||
{ key: 'amount', label: '充值金额' },
|
||||
@@ -53,4 +53,3 @@ export function RechargeRecordsPage({ rechargeRows, apiLoading, apiError, refres
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiErro
|
||||
<ApiNotice loading={apiLoading} error={apiError} onRetry={refreshApi} />
|
||||
{actionError ? <Alert title="操作失败" tone="danger">{actionError}</Alert> : null}
|
||||
<Panel title="角色列表" aside={<Badge tone="neutral">{roleRows.length} 个角色</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={roleRows.map((role) => ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[
|
||||
<SimpleTable loading={apiLoading} rows={roleRows.map((role) => ({ ...role, type: role.builtIn ? '系统内置' : '自定义', userCount: role.userCount ?? roleUserCounts[role.id] ?? 0, permissionCount: role.permissions.length }))} columns={[
|
||||
{ key: 'name', label: '角色名称' },
|
||||
{ key: 'type', label: '类型' },
|
||||
{ key: 'description', label: '角色说明' },
|
||||
|
||||
@@ -81,7 +81,7 @@ export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiErro
|
||||
<Button variant="outline" onClick={() => { setKeyword(''); setRoleFilter('all'); setStatusFilter('all'); }}>重置</Button>
|
||||
</Toolbar>
|
||||
<Panel title="用户列表" aside={<Badge tone="neutral">共 {visibleUsers.length} 个用户</Badge>} className="wide-panel">
|
||||
<SimpleTable rows={visibleUsers.map((user) => ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[
|
||||
<SimpleTable loading={apiLoading} rows={visibleUsers.map((user) => ({ ...user, roleName: roleMap.get(user.roleId) || '-' }))} columns={[
|
||||
{ key: 'username', label: '用户名' },
|
||||
{ key: 'name', label: '姓名' },
|
||||
{ key: 'phone', label: '手机号' },
|
||||
|
||||
@@ -235,7 +235,7 @@ export function VendorGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRows
|
||||
</Toolbar>
|
||||
<section className="content-grid">
|
||||
<Panel title="落地网关列表" className="wide-panel">
|
||||
<SimpleTable rows={gatewayRows} columns={[
|
||||
<SimpleTable loading={apiLoading} rows={gatewayRows} columns={[
|
||||
{ key: 'vendor', label: '供应商名称', width: '156px', className: 'table-cell-compact' },
|
||||
{ key: 'name', label: '落地网关名称', width: '168px', className: 'table-cell-compact' },
|
||||
{ key: 'authMode', label: '认证方式' },
|
||||
|
||||
@@ -86,7 +86,7 @@ export function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineG
|
||||
</Toolbar>
|
||||
<section className="content-grid">
|
||||
<Panel title="落地线路组列表" className="wide-panel">
|
||||
<SimpleTable rows={lineGroupRows} columns={[
|
||||
<SimpleTable loading={apiLoading} rows={lineGroupRows} columns={[
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'lineCount', label: '线路数量', render: (row) => row.gatewayIds.length },
|
||||
{ key: 'customerGatewayCount', label: '使用客户网关数' },
|
||||
|
||||
@@ -114,7 +114,7 @@ export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiL
|
||||
</Toolbar>
|
||||
<section className="master-detail">
|
||||
<Panel title="供应商列表" className="wide-panel">
|
||||
<SimpleTable rows={vendorRows} columns={[
|
||||
<SimpleTable loading={apiLoading} rows={vendorRows} columns={[
|
||||
{ key: 'id', label: '供应商 ID', width: '112px', className: 'table-cell-compact' },
|
||||
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
|
||||
{ key: 'balance', label: '余额' },
|
||||
|
||||
@@ -1919,3 +1919,977 @@ button:disabled {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
/* CMPP platform design alignment */
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--brand: #12121a;
|
||||
--brand-2: #1c1c27;
|
||||
--accent: #d9c3a0;
|
||||
--accent-soft: #f6efe4;
|
||||
--selected: #2563eb;
|
||||
--selected-hover: #1d4ed8;
|
||||
--selected-soft: #eff6ff;
|
||||
--danger: #dc2626;
|
||||
--danger-soft: #fef2f2;
|
||||
--warning: #d97706;
|
||||
--warning-soft: #fffbeb;
|
||||
--success: #16a34a;
|
||||
--success-soft: #ecfdf3;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f3f4f6;
|
||||
--page: #f6f7f9;
|
||||
--line: #e5e7eb;
|
||||
--line-strong: #d1d5db;
|
||||
--text: #1f2937;
|
||||
--ink: #111827;
|
||||
--muted: #6b7280;
|
||||
--subtle: #9ca3af;
|
||||
--shadow: 0 1px 2px rgba(18, 18, 26, 0.08);
|
||||
--shadow-hover: 0 8px 24px rgba(18, 18, 26, 0.08);
|
||||
--focus-ring: 0 0 0 3px rgba(37, 99, 235, 0.18);
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--sidebar-width: 248px;
|
||||
--sidebar-collapsed-width: 84px;
|
||||
--topbar-height: 64px;
|
||||
--content-max-width: 1440px;
|
||||
--control-height-sm: 32px;
|
||||
--control-height-md: 38px;
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
html {
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--page);
|
||||
color: var(--text);
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.prototype-app {
|
||||
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prototype-app.sidebar-collapsed {
|
||||
grid-template-columns: var(--sidebar-collapsed-width) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
height: 100vh;
|
||||
padding: 24px 16px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: flex;
|
||||
grid-template-columns: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 8px 8px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.brand-expanded {
|
||||
flex: 1 1 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.brand-logo-expanded {
|
||||
width: min(100%, 132px);
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.brand-sip-text {
|
||||
color: var(--ink);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.brand-logo-collapsed {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
flex: 0 0 auto;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
color: var(--selected);
|
||||
background: var(--selected-soft);
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
flex: 1 1 auto;
|
||||
gap: 20px;
|
||||
min-height: 0;
|
||||
margin-right: -16px;
|
||||
padding-right: 14px;
|
||||
overflow-y: auto;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar nav::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar nav::-webkit-scrollbar-track,
|
||||
.sidebar nav::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar nav::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sidebar:hover nav {
|
||||
scrollbar-color: rgba(37, 99, 235, 0.24) transparent;
|
||||
}
|
||||
|
||||
.sidebar:hover nav::-webkit-scrollbar-thumb {
|
||||
background: rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.sidebar nav:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-group p {
|
||||
margin: 0 0 8px;
|
||||
padding: 0 12px;
|
||||
color: var(--subtle);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.nav-group button {
|
||||
min-height: 42px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.nav-group button:hover,
|
||||
.nav-group button.active {
|
||||
color: var(--selected);
|
||||
background: var(--selected-soft);
|
||||
}
|
||||
|
||||
.nav-group button.active {
|
||||
box-shadow: inset 3px 0 0 var(--selected);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-short {
|
||||
background: var(--selected-soft);
|
||||
color: var(--selected);
|
||||
}
|
||||
|
||||
.sidebar-collapsed .brand-block {
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar {
|
||||
align-items: center;
|
||||
padding-right: 12px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .sidebar nav {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .nav-group {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .nav-group button {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .nav-group button.active {
|
||||
box-shadow: inset 0 -3px 0 var(--selected);
|
||||
}
|
||||
|
||||
.sidebar-collapsed .nav-group button.active .nav-short {
|
||||
color: #ffffff;
|
||||
background: var(--selected);
|
||||
}
|
||||
|
||||
.main-area {
|
||||
display: grid;
|
||||
grid-template-rows: var(--topbar-height) minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: var(--topbar-height);
|
||||
padding: 0 28px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.topbar strong {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
width: 100%;
|
||||
max-width: var(--content-max-width);
|
||||
gap: 20px;
|
||||
align-content: start;
|
||||
grid-auto-rows: max-content;
|
||||
min-height: 0;
|
||||
padding: 28px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination-actions,
|
||||
.pagination-actions label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pagination-actions select {
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
padding: 0 28px 0 10px;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 247, 249, 0.96)),
|
||||
var(--page);
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.page-title,
|
||||
.toolbar,
|
||||
.prototype-panel,
|
||||
.metric-card,
|
||||
.modal-dialog,
|
||||
.drawer-panel,
|
||||
.match-card,
|
||||
.config-card,
|
||||
.prefix-picker,
|
||||
.permission-group,
|
||||
.log-summary {
|
||||
border-color: var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.login-panel,
|
||||
.page-title,
|
||||
.toolbar,
|
||||
.prototype-panel,
|
||||
.metric-card {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
align-items: flex-end;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-title h1 {
|
||||
color: var(--ink);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.page-title p {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.sub-toolbar {
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sub-toolbar,
|
||||
.match-card,
|
||||
.config-card,
|
||||
.rank-list div,
|
||||
.code-grid div,
|
||||
.kv,
|
||||
.ops-row,
|
||||
.scope-list span,
|
||||
.role-grid div,
|
||||
.drawer-toolbar,
|
||||
.log-summary {
|
||||
background: var(--surface-muted);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.prototype-panel {
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
color: var(--ink);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.content-grid,
|
||||
.master-detail {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.table-shell,
|
||||
.ui-table-wrap {
|
||||
border-color: var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.proto-table,
|
||||
.ui-table {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.proto-table th,
|
||||
.ui-table th {
|
||||
height: 48px;
|
||||
color: var(--muted);
|
||||
background: #fbfbfc;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.proto-table td,
|
||||
.ui-table td {
|
||||
height: 64px;
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--line);
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.proto-table tbody tr:hover,
|
||||
.proto-table tbody tr.is-selected,
|
||||
.ui-table tbody tr:hover {
|
||||
background: #fbfbfc;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ui-button {
|
||||
height: var(--control-height-md);
|
||||
max-width: 100%;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.ui-button-primary {
|
||||
color: #ffffff;
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.ui-button-primary:hover {
|
||||
background: var(--brand-2);
|
||||
}
|
||||
|
||||
.ui-button-secondary {
|
||||
color: #ffffff;
|
||||
background: var(--selected);
|
||||
border-color: var(--selected);
|
||||
}
|
||||
|
||||
.ui-button-secondary:hover {
|
||||
background: var(--selected-hover);
|
||||
}
|
||||
|
||||
.ui-button-outline,
|
||||
.ui-button-ghost {
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.ui-button-outline:hover,
|
||||
.ui-button-ghost:hover {
|
||||
color: var(--selected);
|
||||
background: var(--selected-soft);
|
||||
border-color: rgba(37, 99, 235, 0.22);
|
||||
}
|
||||
|
||||
.ui-button-danger {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-button-sm {
|
||||
height: var(--control-height-sm);
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ui-button-icon-only {
|
||||
width: var(--control-height-md);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-field {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ui-field-label {
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-field-hint,
|
||||
.ui-field-error {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ui-input {
|
||||
min-height: var(--control-height-md);
|
||||
padding: 8px 12px;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border-color: var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.ui-input::placeholder {
|
||||
color: var(--subtle);
|
||||
}
|
||||
|
||||
.ui-input:focus {
|
||||
border-color: var(--selected);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.ui-textarea {
|
||||
min-height: 116px;
|
||||
}
|
||||
|
||||
.ui-check,
|
||||
.ui-switch {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ui-check-box,
|
||||
.ui-radio-dot {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.ui-switch-track {
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.ui-tabs {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ui-tab-list {
|
||||
gap: 8px;
|
||||
border-bottom-color: var(--line);
|
||||
}
|
||||
|
||||
.ui-tab-list button {
|
||||
min-height: 38px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-tab-list button.is-active {
|
||||
color: var(--selected);
|
||||
}
|
||||
|
||||
.ui-badge {
|
||||
min-height: 24px;
|
||||
padding: 2px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-badge-neutral {
|
||||
color: var(--muted);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.ui-badge-brand {
|
||||
color: #b99763;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.ui-badge-info {
|
||||
color: var(--selected);
|
||||
background: var(--selected-soft);
|
||||
}
|
||||
|
||||
.ui-badge-success {
|
||||
color: var(--success);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.ui-badge-warning {
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
|
||||
.ui-badge-danger {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.ui-alert {
|
||||
color: #1e40af;
|
||||
background: var(--selected-soft);
|
||||
border-color: #bfdbfe;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.ui-alert-warning {
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
|
||||
.ui-alert-success {
|
||||
color: var(--success);
|
||||
background: var(--success-soft);
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.ui-alert-danger {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
max-height: min(760px, calc(100vh - 48px));
|
||||
overflow: hidden;
|
||||
box-shadow: 0 18px 48px rgba(18, 18, 26, 0.12);
|
||||
}
|
||||
|
||||
.modal-head,
|
||||
.drawer-head {
|
||||
padding: 20px 24px;
|
||||
border-bottom-color: var(--line);
|
||||
}
|
||||
|
||||
.modal-head h2,
|
||||
.drawer-head h2 {
|
||||
color: var(--ink);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-body,
|
||||
.drawer-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-actions,
|
||||
.drawer-actions {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-state strong {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mini-chart,
|
||||
.line-chart {
|
||||
background: linear-gradient(to top, #eef1f5 1px, transparent 1px);
|
||||
background-size: 100% 44px;
|
||||
}
|
||||
|
||||
.ui-segmented {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.ui-segmented button {
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-segmented button.is-active {
|
||||
color: var(--selected);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.ui-slider {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(160px, 2fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ui-slider input {
|
||||
accent-color: var(--selected);
|
||||
}
|
||||
|
||||
.ui-slider output {
|
||||
color: var(--selected);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.prototype-app,
|
||||
.prototype-app.sidebar-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.sidebar-collapsed .sidebar {
|
||||
align-items: stretch;
|
||||
height: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 260px;
|
||||
margin-right: -16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
|
||||
.nav-group p {
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-group button,
|
||||
.sidebar-collapsed .nav-group button {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 38px;
|
||||
justify-content: flex-start;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .brand-block {
|
||||
justify-items: stretch;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .brand-expanded {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .brand-logo-expanded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .brand-logo-collapsed,
|
||||
.sidebar-collapsed .nav-short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-collapsed .nav-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.topbar > div:first-child {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.topbar-right > span:not(.ui-badge),
|
||||
.topbar-right > strong {
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.topbar-right .ui-button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
overflow-y: visible;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-head,
|
||||
.modal-body,
|
||||
.drawer-head,
|
||||
.drawer-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ui-slider {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* UI performance remediation: stable async states and readable wide tables. */
|
||||
.page-loading-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgb(15 23 42 / 38%);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.page-loading-dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: min(420px, 100%);
|
||||
padding: 22px 24px;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 60px rgb(15 23 42 / 24%);
|
||||
}
|
||||
|
||||
.page-loading-dialog > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.page-loading-dialog strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.page-loading-dialog span:not(.page-loading-spinner) {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.page-loading-spinner {
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid color-mix(in srgb, var(--selected) 22%, transparent);
|
||||
border-top-color: var(--selected);
|
||||
border-radius: 50%;
|
||||
animation: page-loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes page-loading-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.table-skeleton-cell {
|
||||
display: block;
|
||||
min-width: 72px;
|
||||
height: 13px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--surface-muted) 20%, var(--border) 45%, var(--surface-muted) 70%);
|
||||
background-size: 220% 100%;
|
||||
animation: table-skeleton-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes table-skeleton-shimmer {
|
||||
to { background-position-x: -220%; }
|
||||
}
|
||||
|
||||
.table-shell {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
.wide-panel .proto-table {
|
||||
min-width: 1040px;
|
||||
}
|
||||
|
||||
.wide-panel .proto-table th:last-child,
|
||||
.wide-panel .proto-table td:last-child {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
background: var(--surface);
|
||||
box-shadow: -10px 0 14px -14px rgb(15 23 42 / 55%);
|
||||
}
|
||||
|
||||
.wide-panel .proto-table th:last-child {
|
||||
z-index: 2;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.proto-table td,
|
||||
.key-value dd,
|
||||
.log-summary p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.proto-table tbody tr {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 52px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-loading-spinner,
|
||||
.table-skeleton-cell {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
|
||||
export function formatDateInput(date) {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
export function lastSevenDays(reference = new Date()) {
|
||||
const end = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 6);
|
||||
return { startDate: formatDateInput(start), endDate: formatDateInput(end) };
|
||||
}
|
||||
|
||||
export function dateRangeParams(startDate, endDate, fromKey = 'startedFrom', toKey = 'startedTo') {
|
||||
const params = {};
|
||||
if (startDate) params[fromKey] = new Date(`${startDate}T00:00:00`).toISOString();
|
||||
if (endDate) params[toKey] = new Date(`${endDate}T23:59:59.999`).toISOString();
|
||||
return params;
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { createLogger } from '@lisglosips/observability';
|
||||
import { PrismaClient } from '@lisglosips/database';
|
||||
import { PrismaRecordingStore, RecordingTransferService, SshRecordingClient } from './transfer.js';
|
||||
import { LocalRecordingClient, PrismaRecordingStore, RecordingTransferService, SshRecordingClient } from './transfer.js';
|
||||
|
||||
const serviceName = process.env.LISGLOSIPS_SERVICE_NAME ?? 'worker-recording';
|
||||
const logger = createLogger(serviceName, process.env.LISGLOSIPS_LOG_LEVEL ?? 'info');
|
||||
|
||||
const sourceMode = process.env.RECORDING_SOURCE_MODE ?? 'ssh';
|
||||
const remoteHost = process.env.RECORDING_REMOTE_HOST ?? 'lisglosips-a';
|
||||
const sshConfig = process.env.RECORDING_SSH_CONFIG;
|
||||
const remoteReadyDir = process.env.RECORDING_REMOTE_READY_DIR ?? '/dev/shm/voip_rec/ready';
|
||||
const localReadyDir = process.env.RECORDING_LOCAL_READY_DIR ?? '/dev/shm/voip_rec/ready';
|
||||
const localRoot = process.env.RECORDING_LOCAL_ROOT ?? '/data/recordings';
|
||||
const scanIntervalMs = Number.parseInt(process.env.RECORDING_SCAN_INTERVAL_MS ?? '10000', 10);
|
||||
const maxFilesPerScan = Number.parseInt(process.env.RECORDING_MAX_FILES_PER_SCAN ?? '50', 10);
|
||||
@@ -17,8 +19,14 @@ let shuttingDown = false;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const prisma = new PrismaClient();
|
||||
const remote = new SshRecordingClient({ host: remoteHost, sshConfig, remoteReadyDir });
|
||||
const service = new RecordingTransferService(remote, new PrismaRecordingStore(prisma), {
|
||||
if (sourceMode !== 'ssh' && sourceMode !== 'local') {
|
||||
throw new Error(`Unsupported RECORDING_SOURCE_MODE: ${sourceMode}`);
|
||||
}
|
||||
const source =
|
||||
sourceMode === 'local'
|
||||
? new LocalRecordingClient({ readyDir: localReadyDir })
|
||||
: new SshRecordingClient({ host: remoteHost, sshConfig, remoteReadyDir });
|
||||
const service = new RecordingTransferService(source, new PrismaRecordingStore(prisma), {
|
||||
localRoot,
|
||||
deleteSourceAfterCopy,
|
||||
maxFilesPerScan
|
||||
@@ -27,8 +35,9 @@ async function main(): Promise<void> {
|
||||
await prisma.$connect();
|
||||
logger.info(
|
||||
{
|
||||
remoteHost,
|
||||
remoteReadyDir,
|
||||
sourceMode,
|
||||
sourceDir: sourceMode === 'local' ? localReadyDir : remoteReadyDir,
|
||||
...(sourceMode === 'ssh' ? { remoteHost } : {}),
|
||||
localRoot,
|
||||
scanIntervalMs,
|
||||
maxFilesPerScan,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
callIdFromStorageKey,
|
||||
LocalRecordingClient,
|
||||
normalizeRemoteRelativePath,
|
||||
type PulledRecordingFile,
|
||||
type RecordingStore,
|
||||
@@ -90,6 +91,35 @@ describe('recording transfer', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('lists, copies, and deletes ready files from a local source directory', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-local-rec-'));
|
||||
const readyDir = path.join(root, 'ready');
|
||||
const outputDir = path.join(root, 'recordings');
|
||||
const sourcePath = path.join(readyDir, '2026', '08', '27', 'call.wav.ready');
|
||||
const client = new LocalRecordingClient({ readyDir });
|
||||
const store = new MemoryStore();
|
||||
const service = new RecordingTransferService(client, store, {
|
||||
localRoot: outputDir,
|
||||
deleteSourceAfterCopy: true,
|
||||
maxFilesPerScan: 10
|
||||
});
|
||||
|
||||
try {
|
||||
await mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await writeFile(sourcePath, 'local-audio');
|
||||
await writeFile(path.join(readyDir, 'ignored.part'), 'incomplete');
|
||||
|
||||
await expect(client.listReadyFiles()).resolves.toEqual([
|
||||
{ relativePath: '2026/08/27/call.wav.ready', bytes: 11 }
|
||||
]);
|
||||
await expect(service.scanOnce()).resolves.toEqual({ moved: 1, skipped: 0, failed: 0 });
|
||||
await expect(readFile(path.join(outputDir, '2026', '08', '27', 'call.wav'), 'utf8')).resolves.toBe('local-audio');
|
||||
await expect(stat(sourcePath)).rejects.toThrow();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps source file when local size verification fails', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
|
||||
const remote = new MemoryRemote(new Map([['bad.wav.ready', Buffer.from('abc')]]));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdir, rename, rm, stat } from 'node:fs/promises';
|
||||
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
@@ -149,6 +149,31 @@ export interface SshRecordingClientOptions {
|
||||
remoteReadyDir: string;
|
||||
}
|
||||
|
||||
export interface LocalRecordingClientOptions {
|
||||
readyDir: string;
|
||||
}
|
||||
|
||||
/** Reads finalized recordings from a directory on the same host as the worker. */
|
||||
export class LocalRecordingClient implements RemoteRecordingClient {
|
||||
constructor(private readonly options: LocalRecordingClientOptions) {}
|
||||
|
||||
async listReadyFiles(): Promise<RemoteRecordingFile[]> {
|
||||
const files: RemoteRecordingFile[] = [];
|
||||
await walkReadyFiles(this.options.readyDir, '', files);
|
||||
return files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
||||
}
|
||||
|
||||
async pullFile(file: RemoteRecordingFile, localPath: string): Promise<void> {
|
||||
const sourcePath = safeJoin(this.options.readyDir, normalizeRemoteRelativePath(file.relativePath));
|
||||
await pipeline(createReadStream(sourcePath), createWriteStream(localPath, { mode: 0o640 }));
|
||||
}
|
||||
|
||||
async deleteFile(file: RemoteRecordingFile): Promise<void> {
|
||||
const sourcePath = safeJoin(this.options.readyDir, normalizeRemoteRelativePath(file.relativePath));
|
||||
await rm(sourcePath);
|
||||
}
|
||||
}
|
||||
|
||||
export class SshRecordingClient implements RemoteRecordingClient {
|
||||
constructor(private readonly options: SshRecordingClientOptions) {}
|
||||
|
||||
@@ -225,6 +250,33 @@ function safeJoin(root: string, relativePath: string): string {
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
async function walkReadyFiles(root: string, relativeDir: string, files: RemoteRecordingFile[]): Promise<void> {
|
||||
const directory = relativeDir ? safeJoin(root, relativeDir) : path.resolve(root);
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
await walkReadyFiles(root, relativePath, files);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith('.ready')) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeRemoteRelativePath(relativePath);
|
||||
const fileStat = await stat(safeJoin(root, normalized));
|
||||
files.push({ relativePath: normalized, bytes: fileStat.size });
|
||||
}
|
||||
}
|
||||
|
||||
function remotePathFor(baseDir: string, relativePath: string): string {
|
||||
return `${baseDir.replace(/\/+$/, '')}/${normalizeRemoteRelativePath(relativePath)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# LisgloSIPS V2 20260630release 稳定版封版报告
|
||||
|
||||
## 封版信息
|
||||
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| 稳定版名称 | `20260630release` |
|
||||
| 封版时间 | 2026-06-30 14:18 +08:00 |
|
||||
| Git commit | `9920575bbae7fbef4929843632f94d687ffbe289` |
|
||||
| B current release | `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500` |
|
||||
| Web 验收地址 | `https://100.90.90.91/` |
|
||||
|
||||
## Git 基线
|
||||
|
||||
封版 tag 与稳定分支:
|
||||
|
||||
- Tag:`20260630release`
|
||||
- Branch:`stable/20260630release`
|
||||
|
||||
后续开发应从该 tag 或分支新建功能分支;需要恢复代码时,可 checkout tag 或 stable 分支。
|
||||
|
||||
## 服务器基线
|
||||
|
||||
### A / TonyHouse
|
||||
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| Tailscale IP | `100.90.90.90` |
|
||||
| OpenSIPS 配置 SHA256 | `f163fa37f7aaa492e12ed496cb82f07d9b0f1030a6ed297855a22d2c4bf1c8a5` |
|
||||
| 热路径 Lua SHA1 | `aff9995dbda2a2425e95a3e25b5d7ce194348ce7` |
|
||||
| 热路径加载结果 | `success` |
|
||||
| OpenSIPS 监听 | `100.90.90.90:15060/udp`、`100.90.90.90:9061/hep_udp` |
|
||||
|
||||
服务状态:
|
||||
|
||||
- `opensips`: active
|
||||
- `rtpengine-daemon`: active
|
||||
- `rtpengine-recording-daemon`: active
|
||||
- `lisglosips-redis-auth-proxy`: active
|
||||
- `lisglosips-node-exporter`: active
|
||||
- `lisglosips-recording-finalize.timer`: active
|
||||
|
||||
已知无关 failed unit:
|
||||
|
||||
- `fwupd-refresh.service`
|
||||
|
||||
### B / YanZi
|
||||
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| Tailscale IP | `100.90.90.91` |
|
||||
| Current release | `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500` |
|
||||
| MySQL 封版备份 | `/data/backups/mysql/20260630T061723Z` |
|
||||
| Redis 封版备份 | `/data/backups/redis/20260630T061723Z` |
|
||||
| Backup service result | `success` |
|
||||
| Preflight | `preflight ok` |
|
||||
|
||||
服务状态:
|
||||
|
||||
- `mysql`: active
|
||||
- `redis-server`: active
|
||||
- `nginx`: active
|
||||
- `lisglosips@api`: active
|
||||
- `lisglosips@cdr-worker`: active
|
||||
- `lisglosips@recording-worker`: active
|
||||
- `lisglosips@config-publisher`: active
|
||||
- `heplify-server`: active
|
||||
- `lisglosips-prometheus`: active
|
||||
- `grafana-server`: active
|
||||
|
||||
API ready:
|
||||
|
||||
```json
|
||||
{"status":"ok","service":"api","checks":{"config":"ok","database":"ok","redis":"ok"}}
|
||||
```
|
||||
|
||||
### T / SteveTable
|
||||
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| Tailscale IP | `100.93.185.30` |
|
||||
| OpenSIPS 配置 SHA256 | `5255dd540d33c3a7bf3db278fe6fe39540b176eb79e7fe12a7a26e5217c263da` |
|
||||
| OpenSIPS 监听 | `100.93.185.30:5060/udp` |
|
||||
|
||||
服务状态:
|
||||
|
||||
- `opensips`: active
|
||||
- `rtpengine-daemon`: active
|
||||
- `rtpengine-recording-daemon`: active
|
||||
- `lisglosips-s28-uas`: active
|
||||
- `apache2`: active
|
||||
- `mariadb`: active
|
||||
|
||||
已知无关 failed unit:
|
||||
|
||||
- `fwupd-refresh.service`
|
||||
|
||||
## 验证结果
|
||||
|
||||
- B release preflight:通过
|
||||
- 远程 Web/API smoke:通过,报告 `tests/reports/REMOTE_SMOKE_20260630T061636Z.md`
|
||||
- 最近完整按钮级浏览器巡检:通过,报告 `tests/reports/REMOTE_WEB_UI_SMOKE_20260629T104040Z.md`
|
||||
- A OpenSIPS 配置检查:通过
|
||||
- T OpenSIPS 配置检查:通过
|
||||
|
||||
## 回滚方式
|
||||
|
||||
### 代码回滚
|
||||
|
||||
```bash
|
||||
git checkout 20260630release
|
||||
# 或
|
||||
git switch stable/20260630release
|
||||
```
|
||||
|
||||
### B 应用回滚到封版 release
|
||||
|
||||
```bash
|
||||
sudo ln -sfn /opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500 /opt/lisglosips/current
|
||||
sudo systemctl restart lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
sudo /opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
|
||||
```
|
||||
|
||||
### 数据恢复点
|
||||
|
||||
封版备份:
|
||||
|
||||
- MySQL:`/data/backups/mysql/20260630T061723Z`
|
||||
- Redis:`/data/backups/redis/20260630T061723Z`
|
||||
|
||||
生产式数据库恢复前必须先在隔离环境验证备份可恢复性,再安排维护窗口执行。
|
||||
|
||||
## 后续开发约束
|
||||
|
||||
- 新功能或修复从 `stable/20260630release` 或 `20260630release` 新建分支。
|
||||
- B 服务器发布必须新增 release 目录,不覆盖 `s53-brand-logo-sip-text-20260630105500`。
|
||||
- A/B/T 服务器配置修改前必须先备份配置并记录回滚命令。
|
||||
- 数据库 schema、服务重启、服务器写操作、SSH、防火墙、数据删除、生产式迁移前,必须先说明影响、风险和回滚点。
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# S57 UI、查询与号码库发布报告(2026-08-27)
|
||||
|
||||
## 发布结论
|
||||
|
||||
- 代码提交:`c36709f366422b9dc5d37156d42d6e2f78bea051`
|
||||
- B 发布目录:`/opt/lisglosips/releases/s57-ui-number-library-20260827T124730Z`
|
||||
- 配置/回滚备份:`/var/backups/lisglosips-s57/20260827T124730Z`
|
||||
- 数据备份:`/data/backups/mysql/20260827T124730Z`、`/data/backups/redis/20260827T124730Z`
|
||||
- 发布前检查:MySQL、Redis、Nginx、API、CDR Worker、录音 Worker、HEP、Prometheus、Grafana 均为 active,API ready 与 Nginx 配置检查通过。
|
||||
- 第一次切换因发布脚本误检查不存在的 `/health` 路径得到 404,自动回滚成功;修正为 `/health/ready` 后重新发布并通过,没有遗留半切换状态。
|
||||
|
||||
## 生产数据结果
|
||||
|
||||
| 数据表 | 生产数量 |
|
||||
| --- | ---: |
|
||||
| 行政城市 | 371 |
|
||||
| 手机前 7 位号段 | 517,258 |
|
||||
| 城市区号 | 321 |
|
||||
| 运营商前三位规则 | 70 |
|
||||
|
||||
- 数据文件上传前后 SHA256 一致。
|
||||
- 生产干跑:`phone.dat` 2502 版本,有效号段 517,258,行政区未匹配 0。
|
||||
- 导入器拒绝向非空号码库重复写入,并在导入前由发布流程完成 MySQL/Redis 备份。
|
||||
- Prisma 并行计数和首屏 25 条直查共 85 ms。
|
||||
|
||||
## 生产接口证据
|
||||
|
||||
以下耗时来自 B 的 API 完成日志:
|
||||
|
||||
| 接口 | 状态 | 服务端耗时 |
|
||||
| --- | ---: | ---: |
|
||||
| `/api/v2/active-calls` | 200 | 首次 46 ms,后续 4-6 ms |
|
||||
| `/api/v2/number-library/cities?take=25&skip=0` | 200 | 9 ms |
|
||||
| `/api/v2/number-library/phone-segments?take=25&skip=0` | 200 | 74 ms |
|
||||
| `/api/v2/cdrs` 最近一周 | 200 | 7 ms |
|
||||
| `/api/v2/recordings` 最近一周 | 200 | 5 ms |
|
||||
| `/api/v2/audit-logs` 最近一周 | 200 | 5 ms |
|
||||
|
||||
当前通话页面已显示“控制面:就绪”,空闲时返回 0 路真实 dialog,不再出现 OpenSIPS control plane unavailable。
|
||||
|
||||
## 浏览器验收
|
||||
|
||||
- 登录态下 Dashboard 只保留两个趋势模块,“客户消费 TOP 10”和“失败响应码分布”已删除。
|
||||
- 客户网关页标题和列表按内容高度从顶部连续排列,截图所示大面积空白消失。
|
||||
- 号码库首屏 25 条,显示 1-25 / 371;手机号码库显示 1-25 / 517,258,上一页/下一页状态正确。
|
||||
- 话单、质检、操作日志日期均默认 `2026-08-21` 至 `2026-08-27`;请求转换为上海时区自然日对应的 UTC 起止值。
|
||||
- 页面 DOM 均有真实业务内容,没有框架错误覆盖层;关键请求均在 API 日志中返回 200。
|
||||
|
||||
## 本地质量门禁
|
||||
|
||||
- ESLint:通过。
|
||||
- TypeScript project build/typecheck:通过。
|
||||
- Web production build:通过。
|
||||
- Vitest:27 个测试文件、98 个测试全部通过。
|
||||
- 号码库导入器本地与生产干跑:均为未匹配 0。
|
||||
|
||||
## 回滚
|
||||
|
||||
如需回滚代码和 API 配置:恢复 `/var/backups/lisglosips-s57/20260827T124730Z/api.env`,将 `/opt/lisglosips/current` 指回该目录内 `previous-release` 记录的版本,并重启 `lisglosips@api`。号码库数据回滚应使用本次发布前 MySQL 备份,不应直接无条件清空四张表。
|
||||
@@ -0,0 +1,28 @@
|
||||
# Server B 单机服务收敛实施方案
|
||||
|
||||
日期:2026-08-27
|
||||
范围:停止 B 到 A 的录音 SSH 依赖,当前生产服务全部留在 B。
|
||||
|
||||
## 结论
|
||||
|
||||
B 已运行 OpenSIPS、RTPEngine、RTPEngine Recording Daemon、Nginx、API、MySQL、Redis、CDR Worker、Recording Worker 与 Config Publisher,CPU、内存和磁盘空间满足当前负载。现有单机化缺口只有录音 Worker 仍配置为 SSH 访问 A;A 不可达会导致该 Worker 周期性失败重启。
|
||||
|
||||
## 改动
|
||||
|
||||
1. Recording Worker 新增 `local` 源模式,递归扫描 B 的 `.ready` 文件,保留大小校验、SHA-256、原子落盘、数据库 upsert 与成功后删除源文件语义。
|
||||
2. B 启用录音 finalizer timer,将 Recording Daemon 生成的稳定 WAV/MP3 从 `incoming` 原子推进到 `ready`。
|
||||
3. Worker systemd 增加 `rtpengine` 附加组,只开放 ready 目录和持久化目录权限。
|
||||
4. B 生产环境改为 `RECORDING_SOURCE_MODE=local`;旧 SSH 文件不立即删除,只作短期回滚材料。
|
||||
5. 不修改 TLS、数据库结构、SIP/RTP 监听地址和防火墙,不迁移历史录音,不停止不可达的 A。
|
||||
|
||||
## 验收
|
||||
|
||||
- 定向单元测试和 TypeScript 构建通过。
|
||||
- finalizer timer、Recording Worker、OpenSIPS、RTPEngine、API、Nginx、MySQL、Redis 均 active。
|
||||
- Recording Worker 启动日志显示 `sourceMode=local`,稳定运行超过原失败周期,且无 SSH 子进程。
|
||||
- API readiness、首页和发布 preflight 通过。
|
||||
- 真实新通话录音应在下一次受控呼叫时补做:`incoming -> ready -> /data/recordings -> recordings READY -> 鉴权播放`。
|
||||
|
||||
## 回滚
|
||||
|
||||
发布前备份 Worker env、systemd drop-in、finalizer 脚本和 unit。异常时停用 finalizer timer,恢复备份配置并将 release symlink 切回上一版本;不删除 B 已落盘录音。
|
||||
@@ -0,0 +1,52 @@
|
||||
# Server B 单机服务收敛发布报告
|
||||
|
||||
发布日期:2026-08-27
|
||||
生产提交:`309bffc7ffa70a760bce21ae59cb6c457c9c57fe`
|
||||
生产 release:`/opt/lisglosips/releases/s56-b-consolidation-20260827T095812Z`
|
||||
|
||||
## 发布结果
|
||||
|
||||
当前服务已全部留在 Server B。Recording Worker 使用 `RECORDING_SOURCE_MODE=local` 读取
|
||||
`/dev/shm/voip_rec/ready`,不再连接 A,也不再产生录音 SSH 子进程。A 未执行任何配置修改或停机操作;B 上旧 SSH 文件暂留作短期回滚材料,但不被当前 env 和进程引用。
|
||||
|
||||
## 代码与配置
|
||||
|
||||
- 新增 `LocalRecordingClient`,保留大小校验、SHA-256、临时文件、原子改名、数据库 upsert 和成功后删除源文件的原语义。
|
||||
- 新增 B finalizer service/timer,每 5 秒将稳定 WAV/MP3 从 `incoming` 推进到 `ready`。
|
||||
- Recording Worker 使用 `SupplementaryGroups=rtpengine` 与 `BindPaths=/dev/shm/voip_rec/ready`;持久化目录仍为 `/data/recordings`。
|
||||
- finalizer 的 `fuser` 调用已按 B 的 psmisc 实现改为兼容语法。
|
||||
- 未修改 TLS、数据库 schema、SIP/RTP 监听地址、防火墙、A 或 T。
|
||||
|
||||
## 备份与回滚点
|
||||
|
||||
- 最终 MySQL 备份:`/data/backups/mysql/20260827T095812Z`
|
||||
- 最终 Redis 备份:`/data/backups/redis/20260827T095813Z`
|
||||
- 最终配置备份:`/var/backups/lisglosips-s56/20260827T095812Z`
|
||||
- 上一可用 release:`/opt/lisglosips/releases/s56-b-consolidation-20260827T093227Z`
|
||||
|
||||
发布脚本会在失败时恢复 previous release、Recording Worker env 与 systemd drop-in,并重启旧 Worker。回滚不删除已持久化录音。
|
||||
|
||||
## 验证证据
|
||||
|
||||
| 检查 | 结果 |
|
||||
| --- | --- |
|
||||
| 定向 Vitest | `apps/worker-recording/src/transfer.spec.ts` 5/5 通过 |
|
||||
| TypeScript / ESLint | Recording Worker 构建及三个变更文件 lint 通过 |
|
||||
| 发布 preflight | MySQL、Redis、Nginx、API、全部 Worker、HOMER、Prometheus、Grafana 通过 |
|
||||
| 核心服务 | OpenSIPS、RTPEngine、Recording Daemon、Web/API/DB/Worker 全部 active |
|
||||
| API 与首页 | readiness HTTP 200;首页 HTTP 200 |
|
||||
| Worker 模式 | 启动日志为 `sourceMode=local`、`sourceDir=/dev/shm/voip_rec/ready` |
|
||||
| 稳定性 | 连续运行 1100 秒,`NRestarts=0`,超过旧 SSH 约 135 秒失败周期 |
|
||||
| SSH 依赖 | Worker 子进程为空;只存在系统管理用 sshd/运维会话 |
|
||||
| 目录可见性 | 主机与 Worker 命名空间 ready 目录设备号/inode 同为 `27:7` |
|
||||
| finalizer 隔离测试 | 19 字节测试文件成功形成 `.ready`,权限 `0640 rtpengine:rtpengine`,测试目录已清理 |
|
||||
| 生效文件一致性 | systemd drop-in 与 finalizer 的仓库/服务器 SHA-256 分别一致 |
|
||||
|
||||
## 剩余验收
|
||||
|
||||
本次未主动发起真实外呼,避免生成计费、CDR 和业务录音。下一次受控真实呼叫需要核对:
|
||||
|
||||
1. Recording Daemon 在 B 生成 `incoming` 文件。
|
||||
2. finalizer 生成 `.ready`。
|
||||
3. Worker 写入 `/data/recordings` 并将数据库状态更新为 `READY`。
|
||||
4. 有 `recordings.play` 权限的账号可播放,直接访问 Nginx internal 路径仍返回 404。
|
||||
@@ -0,0 +1,45 @@
|
||||
# LisgloSIPS UI 性能整改 B 发布报告
|
||||
|
||||
## 发布信息
|
||||
|
||||
| 项目 | 值 |
|
||||
| --- | --- |
|
||||
| 发布时间 | 2026-08-27 15:39 +08:00 |
|
||||
| 代码提交 | `ecb567c0dacd749607808fbed09648267757be6f` |
|
||||
| Git 分支 | `main`,已推送 `origin/main` |
|
||||
| 新 release | `/opt/lisglosips/releases/s55-ui-performance-20260827153500` |
|
||||
| 回滚 release | `/opt/lisglosips/releases/s54-cmpp-ui-style-20260702103305` |
|
||||
| Artifact SHA256 | `acc49bb21506448527732e965c345a5e71e83090781be76df750a93cffccaece` |
|
||||
| Web 资产 | `index-DcpB-OOG.js`、`index-u-EPfKoT.css` |
|
||||
|
||||
## 发布门禁
|
||||
|
||||
- 本地 ESLint、TypeScript、Prisma schema validate 通过。
|
||||
- Vitest 26 个测试文件、95 项测试通过。
|
||||
- 本地 Web production build 和发布结构检查通过。
|
||||
- B 使用 Linux `node_modules` 从精确 Git bundle 构建,workspace build 通过。
|
||||
- Linux release artifact 生成成功,共 19,681 个文件。
|
||||
- 发布前 MySQL、Redis 备份 SHA256 校验通过:`/data/backups/mysql/20260827T073857Z`、`/data/backups/redis/20260827T073857Z`。
|
||||
|
||||
## 发布后验证
|
||||
|
||||
- `current` 已切换到 s55,`.deployed-commit` 写入业务提交。
|
||||
- Nginx 配置测试通过,首页返回 200 并引用新 Web 资产。
|
||||
- B release preflight 通过;MySQL、Redis、Nginx、API、CDR Worker、Recording Worker、Heplify、Prometheus、Grafana 在 preflight 时均为 active。
|
||||
- API live/ready、验证码接口和外部匿名 smoke 全部通过,报告见 `tests/reports/REMOTE_SMOKE_20260827T073955Z.md`。
|
||||
- 真实管理员只读回读:Dashboard overview 1,843ms、24 个趋势桶;话单接口 2,357ms、默认 `take=25`、返回 25/97 条。
|
||||
|
||||
## 已知环境问题
|
||||
|
||||
B 到 A `100.90.90.90` 的 Tailscale ping 和录音专用 SSH 当前超时。Recording Worker 启动后在扫描 A 录音目录时约 135 秒失败,systemd 会自动拉起。该模块在本次提交中没有代码变化,回滚 s55不能恢复 A 网络;待 A 上线后应复核 Worker 连续稳定和录音积压。
|
||||
|
||||
完整 Playwright UI 巡检未完成,原因是本机没有对应 Chromium 二进制;未为本次发布临时下载安装浏览器。真实认证 API、页面静态资产和匿名 Web smoke 已通过。
|
||||
|
||||
## 回滚命令
|
||||
|
||||
```bash
|
||||
sudo /opt/lisglosips/current/infra/server-b/s30/lisglosips-release-rollback.sh \
|
||||
/opt/lisglosips/releases/s54-cmpp-ui-style-20260702103305
|
||||
```
|
||||
|
||||
本次没有数据库迁移、TLS、Redis 热路径或 A/T 配置回滚项。
|
||||
@@ -0,0 +1,29 @@
|
||||
# Server B 开机自启发布报告(2026-08-30)
|
||||
|
||||
## 结论
|
||||
|
||||
- 代码提交:`406b374`。
|
||||
- systemd 项目编组:`lisglosips.target`,状态 `active / enabled`。
|
||||
- 开机验收:`lisglosips-boot-check.service`,状态 `active (exited) / enabled`,最近执行结果 `0/SUCCESS`。
|
||||
- 配置备份:`/var/backups/lisglosips-s58/20260830T131241Z`。
|
||||
- 本次未重启服务器,未中断现有通话服务;真正的冷启动验收应安排维护窗口。
|
||||
|
||||
## 本次补齐
|
||||
|
||||
- `lisglosips@recording-worker.service`:由 `inactive / disabled` 修正为 `active / enabled`。
|
||||
- `lisglosips@config-publisher.service`:由 `inactive / disabled` 修正为 `active / enabled`。
|
||||
- API、CDR Worker 两个实例继续保持 `active / enabled`。
|
||||
- 备份 timer 和录音整理 timer 继续保持 `active / enabled`。
|
||||
|
||||
## 现场验证
|
||||
|
||||
- API `/api/v2/health/ready`:`status=ok`,数据库和 Redis 检查正常。
|
||||
- Nginx 配置、OpenSIPS 配置和发布 preflight:通过。
|
||||
- systemd failed units:0。
|
||||
- Recording Worker:本机录音模式启动成功,当前内存约 22 MB,重启次数 0。
|
||||
- Config Publisher:启动成功,当前内存约 29 MB、峰值约 39 MB,重启次数 0。
|
||||
- Config Publisher 的 Redis 用户仍无 `INFO` 权限,因此 ioredis 会输出“跳过 ready check”的提示;实际 Redis 连接和 Worker 启动成功,此提示不阻断服务,但后续可单独收敛日志或关闭 ready check。
|
||||
|
||||
## 架构说明
|
||||
|
||||
业务进程仍由独立 systemd unit 托管,项目 target 仅负责统一编组和开机拉起,启动脚本负责就绪等待与验收。这比 `rc.local`、`nohup` 或单个常驻 shell 脚本更符合生产环境常见做法。
|
||||
Binary file not shown.
@@ -2,6 +2,21 @@
|
||||
|
||||
> 任务:S24 - 录音搬运与播放
|
||||
> 完成时间:2026-06-21 20:20 +08:00
|
||||
> 当前模式:S56 - Server B 本机录音闭环(2026-08-27)
|
||||
|
||||
## 0. 当前生产架构(S56)
|
||||
|
||||
录音不再经过 B 到 A 的专用 SSH。Server B 上的 RTPEngine Recording Daemon 先写入
|
||||
`/dev/shm/voip_rec/incoming`,本机 finalizer 将稳定文件原子改名为
|
||||
`/dev/shm/voip_rec/ready/**/*.ready`,Recording Worker 再复制、校验并持久化到
|
||||
`/data/recordings`,最后写入数据库并删除本机 `.ready` 源文件。
|
||||
|
||||
```text
|
||||
B RTPEngine -> B incoming -> B finalizer -> B ready
|
||||
-> B Recording Worker -> B /data/recordings + MySQL
|
||||
```
|
||||
|
||||
旧的 B -> A SSH 配置仅作为短期回滚材料保留,不参与当前服务。S24 以下章节保留为历史实施记录。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
@@ -26,8 +41,23 @@ S24 完成录音闭环的最小能力:
|
||||
|
||||
## 3. Worker 环境变量
|
||||
|
||||
S56 当前生产配置:
|
||||
|
||||
```text
|
||||
DATABASE_URL
|
||||
RECORDING_SOURCE_MODE=local
|
||||
RECORDING_LOCAL_READY_DIR=/dev/shm/voip_rec/ready
|
||||
RECORDING_LOCAL_ROOT=/data/recordings
|
||||
RECORDING_SCAN_INTERVAL_MS=10000
|
||||
RECORDING_MAX_FILES_PER_SCAN=50
|
||||
RECORDING_DELETE_SOURCE_AFTER_COPY=true
|
||||
```
|
||||
|
||||
`RECORDING_SOURCE_MODE=ssh` 仍受代码支持,以下变量只用于回滚到旧架构:
|
||||
|
||||
```text
|
||||
DATABASE_URL
|
||||
RECORDING_SOURCE_MODE=ssh
|
||||
RECORDING_REMOTE_HOST=lisglosips-a
|
||||
RECORDING_SSH_CONFIG=/etc/lisglosips/recording/ssh_config
|
||||
RECORDING_REMOTE_READY_DIR=/dev/shm/voip_rec/ready
|
||||
@@ -37,12 +67,16 @@ RECORDING_MAX_FILES_PER_SCAN=50
|
||||
RECORDING_DELETE_SOURCE_AFTER_COPY=true
|
||||
```
|
||||
|
||||
本地开发可使用 `.codex-private/ssh/config`;生产或 B 端 systemd 应使用 `/etc/lisglosips/recording/` 下的受控 key 和 known_hosts。
|
||||
SSH 模式本地开发可使用 `.codex-private/ssh/config`;生产回滚时才使用 `/etc/lisglosips/recording/` 下的受控 key 和 known_hosts。
|
||||
|
||||
## 4. 服务器变更
|
||||
|
||||
### Server B
|
||||
|
||||
- S56 新增 `lisglosips-recording-finalize.timer`,每 5 秒检查本机稳定录音文件。
|
||||
- Recording Worker 以 `lisglo-recorder` 身份运行,通过 `SupplementaryGroups=rtpengine` 访问 ready 目录。
|
||||
- systemd 只开放 `/dev/shm/voip_rec/ready` 与 `/data/recordings` 所需写权限。
|
||||
- Worker 使用 `RECORDING_SOURCE_MODE=local`,运行期不再启动 `ssh` 子进程。
|
||||
- 新增 `/etc/lisglosips/recording/a_pull_ed25519`,权限 `0640 root:lisglosips`。
|
||||
- 新增 `/etc/lisglosips/recording/known_hosts`。
|
||||
- 录音持久化目录仍为 `/data/recordings`,由 `lisglo-recorder:lisglosips` 管理。
|
||||
@@ -109,7 +143,7 @@ location /_recordings/ {
|
||||
|
||||
## 7. 当前限制
|
||||
|
||||
B 当前 `/opt/lisglosips/current` 仍是 S05 placeholder release,未切换到完整 monorepo API/Worker release。因此 S24 本次完成代码、构建、服务器私网拉取链路和 Nginx 内部播放保护验证;正式 `lisglosips@recording-worker.service` 随完整应用 release 发布时启用。
|
||||
S24 记载的 placeholder 限制已经失效;B 已运行完整 monorepo API/Worker release。S56 不迁移 A 的历史录音,也不自动删除旧 SSH key;旧凭据待本地闭环稳定运行并完成真实录音验收后再单独清理。
|
||||
|
||||
## 8. 回滚
|
||||
|
||||
@@ -125,8 +159,11 @@ sudo chmod 0750 /dev/shm/voip_rec/ready
|
||||
Server B:
|
||||
|
||||
```bash
|
||||
sudo rm -rf /etc/lisglosips/recording
|
||||
sudo rm -rf /data/recordings/s24-smoke
|
||||
sudo systemctl disable --now lisglosips-recording-finalize.timer
|
||||
sudo cp /var/backups/lisglosips-s56/<timestamp>/recording-worker.env /etc/lisglosips/recording-worker.env
|
||||
sudo cp /var/backups/lisglosips-s56/<timestamp>/10-s28-recording.conf /etc/systemd/system/lisglosips@recording-worker.service.d/10-s28-recording.conf
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart lisglosips@recording-worker
|
||||
```
|
||||
|
||||
代码回滚:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Server B 单机服务开机自启 Runbook
|
||||
|
||||
## 方案
|
||||
|
||||
生产进程继续由各自的 systemd unit 管理,使用 `lisglosips.target` 编组整套单 B 服务。`lisglosips-start-b` 是人工启动和开机后验收脚本,不直接常驻托管业务进程。
|
||||
|
||||
这种方式比 `rc.local`、桌面启动项或后台 shell 脚本更适合生产环境:systemd 能分别记录日志、限制权限、处理依赖、实施失败重启,并准确显示单个服务状态。
|
||||
|
||||
## 自启范围
|
||||
|
||||
- 基础设施:Tailscale、MySQL、Redis、PostgreSQL、Nginx。
|
||||
- 通信:OpenSIPS、RTPEngine、RTPEngine Recording、Redis AUTH Proxy、Redis 热路径加载。
|
||||
- 应用:API、CDR Worker、Recording Worker、Config Publisher。
|
||||
- 信令与监控:HEP、HOMER、Prometheus、Grafana、Node/MySQL/PostgreSQL/Redis Exporter。
|
||||
- 定时任务:每日数据备份、每 5 秒录音整理。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
sudo /opt/lisglosips/current/infra/server-b/s58/deploy-autostart.sh
|
||||
```
|
||||
|
||||
安装流程会备份已有文件、安装 target 和开机检查服务、启用四个应用实例、启动整套 target,并运行 API ready、Nginx、OpenSIPS 和 preflight 检查。失败时自动撤销本次新建的 enable 链接并恢复旧文件。
|
||||
|
||||
## 日常操作
|
||||
|
||||
```bash
|
||||
sudo systemctl start lisglosips.target
|
||||
sudo /usr/local/sbin/lisglosips-start-b
|
||||
systemctl status lisglosips.target lisglosips-boot-check.service
|
||||
systemctl --failed
|
||||
```
|
||||
|
||||
不建议使用 `kill`、`nohup` 或重复启动脚本托管进程。单个服务变更仍应使用 `systemctl restart <unit>`,避免无关服务中断。
|
||||
|
||||
## 验收
|
||||
|
||||
安装后检查 `lisglosips.target`、`lisglosips-boot-check.service`、API、CDR Worker、Recording Worker、Config Publisher 是否 enabled。首次真正重启验收应安排维护窗口,记录重启时间、所有服务状态、API ready、当前通话 MI、录音入库和配置发布结果。
|
||||
+82
-12
@@ -35,8 +35,8 @@
|
||||
| 环境 | 用途 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 本地开发 | 单元、服务、API e2e、前端构建 | Windows 工作区,Node.js 22+,pnpm 10.33.0,Vitest |
|
||||
| Server A | SIP/RTP/录音/HEP/Exporter | OpenSIPS、RTPEngine、录音 tmpfs、Redis 热路径 |
|
||||
| Server B | Web/API/Worker/DB/监控 | Nginx、API、Redis、MySQL、Worker、HOMER、Prometheus/Grafana |
|
||||
| Server A | 历史节点/回滚参考 | S56 起不承载当前生产服务,不参与录音传输 |
|
||||
| Server B | SIP/RTP/录音/Web/API/Worker/DB/监控 | OpenSIPS、RTPEngine、录音 tmpfs、Nginx、API、Redis、MySQL、Worker、HOMER、Prometheus/Grafana |
|
||||
| Server T | 客户与落地模拟 | SIP 注册、呼叫、失败场景、并发呼叫脚本 |
|
||||
| 阿里云迁移环境 | 上线前复验 | 生产网络、安全组、正式 TLS、数据盘、备份恢复和灰度呼叫必须重新执行 |
|
||||
|
||||
@@ -1331,18 +1331,18 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
|
||||
- 只读质检账号 `quality_viewer_001`:包含 `quality.view`,不包含 `quality.manage`、`recordings.play`。
|
||||
- 至少 1 条成功通话 CDR,关联 READY 录音 `rec_ready_seed_001`,storageKey 指向 B 本地录音数据盘。
|
||||
- 至少 1 条 PENDING 录音 `rec_pending_seed_001`,用于播放和质检不可用状态。
|
||||
- A 侧 `/dev/shm/voip_rec` 或测试目录可生成 ready 文件,B 侧录音存储目录可写。
|
||||
- B 侧 `/dev/shm/voip_rec` 可生成 ready 文件,`/data/recordings` 可写;Worker 配置为本地源模式。
|
||||
|
||||
#### REC-001 录音搬运成功
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P0 |
|
||||
| 目的 | 验证 Recording Worker 从 A 拉取 ready 文件,校验大小/hash 后写入 B,并安全删除源文件。 |
|
||||
| 前置条件 | A 侧存在测试录音文件和 ready 标记;B 侧 Recording Worker 可通过私网访问 A;关联 CDR/callId 可匹配。 |
|
||||
| 目的 | 验证 Recording Worker 从 B 本机读取 ready 文件,校验大小/hash 后持久化,并安全删除 tmpfs 源文件。 |
|
||||
| 前置条件 | B 侧存在测试录音文件和 ready 标记;本机 finalizer 与 Recording Worker active;关联 CDR/callId 可匹配。 |
|
||||
| 测试数据 | 文件名包含 Call-ID;ready 内容包含相对路径和 size;文件大小固定,例如 55758 bytes。 |
|
||||
| 步骤 | 1. 在 A 侧生成测试录音和 ready 文件。2. 启动或触发 Recording Worker。3. 查询 B 侧录音文件、数据库 recordings 表和 Worker 日志。4. 检查 A 源文件和 ready 文件。 |
|
||||
| 预期结果 | B 侧文件存在,大小/hash 与 A 源文件一致;recording 状态为 `READY`;storageKey 为安全相对路径;A 源文件在校验成功后删除;日志记录成功搬运。 |
|
||||
| 步骤 | 1. 在 B 侧生成测试录音,由 finalizer 形成 ready 文件。2. 启动或触发 Recording Worker。3. 查询 B 持久化文件、数据库 recordings 表和 Worker 日志。4. 检查 B ready 源文件。 |
|
||||
| 预期结果 | B 持久化文件存在,大小/hash 与 ready 源文件一致;recording 状态为 `READY`;storageKey 为安全相对路径;B ready 源文件在校验成功后删除;日志记录成功搬运,进程树中无录音 SSH。 |
|
||||
| 数据检查 | recordings 记录包含 callId、rawCdrId 或关联键、fileSize、checksum、storageKey、readyAt;状态从 PENDING 变为 READY。 |
|
||||
| 安全检查 | Worker 只处理配置目录下文件;不会跟随任意绝对路径或 `../` 路径。 |
|
||||
|
||||
@@ -1351,13 +1351,13 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P0 |
|
||||
| 目的 | 验证复制后大小/hash 不一致时不删除 A 源文件,避免录音丢失。 |
|
||||
| 目的 | 验证复制后大小/hash 不一致时不删除 B 本机 ready 源文件,避免录音丢失。 |
|
||||
| 前置条件 | 可构造错误 ready size、截断文件或模拟 B 写入失败。 |
|
||||
| 测试数据 | ready size 与实际文件大小不一致。 |
|
||||
| 步骤 | 1. 生成 size 不一致的 ready 文件。2. 执行 Recording Worker。3. 查询 A/B 文件和 recordings 状态。 |
|
||||
| 预期结果 | Worker 标记失败或保持待重试;A 源文件保留;B 不产生 READY 记录;错误日志可定位。 |
|
||||
| 步骤 | 1. 生成 size 不一致的 ready 文件。2. 执行 Recording Worker。3. 查询 B ready/持久化文件和 recordings 状态。 |
|
||||
| 预期结果 | Worker 标记失败或保持待重试;B ready 源文件保留;持久化目录不产生有效成品且数据库不误标 READY;错误日志可定位。 |
|
||||
| 数据检查 | recordings 不应被误标记 READY;如生成 FAILED 状态,应记录失败原因和重试信息。 |
|
||||
| 安全检查 | 失败日志不泄露 SSH 私钥、完整内部路径或连接凭据。 |
|
||||
| 安全检查 | 失败日志不泄露数据库连接凭据;Worker 不访问配置目录之外的文件。 |
|
||||
|
||||
#### REC-003 播放接口鉴权与权限
|
||||
|
||||
@@ -1655,6 +1655,42 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
|
||||
| 数据检查 | release 目录和 public 目录一致。 |
|
||||
| 安全检查 | 发布不覆盖后端 env、node_modules 或用户上传录音。 |
|
||||
|
||||
#### WEB-007 慢页面统一加载反馈
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P0 |
|
||||
| 目的 | 验证页面请求超过 2 秒时才显示加载弹窗,完成或失败后立即隐藏。 |
|
||||
| 前置条件 | 管理员已登录;浏览器可将目标接口延迟到 1.5 秒、2.5 秒和失败三种状态。 |
|
||||
| 步骤 | 1. 分别以三种延迟进入 Dashboard、话单、质检、号码库。2. 观察弹窗和列表状态。3. 在请求过程中切换菜单。 |
|
||||
| 预期结果 | 2 秒内无弹窗;超过 2 秒出现“页面加载中”;请求完成、失败或被页面切换取消后弹窗立即消失;加载阶段只显示骨架,不出现“暂无数据”。 |
|
||||
| 数据检查 | 页面切换会取消上一页面未完成请求,不产生旧响应覆盖新页面。 |
|
||||
| 安全检查 | 弹窗和错误信息不显示 token、SQL 或内部堆栈。 |
|
||||
|
||||
#### WEB-008 Dashboard 按页面取数
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P0 |
|
||||
| 目的 | 验证登录首页只请求首页概览数据,不再预加载全站接口。 |
|
||||
| 前置条件 | 管理员登录,浏览器 Network 保留日志。 |
|
||||
| 步骤 | 1. 清空 Network。2. 登录或刷新首页。3. 检查 `/api/v2/dashboard/overview` 及其他业务接口。4. 切换到客户、供应商后复查。 |
|
||||
| 预期结果 | 首页使用一个 overview 请求并行取得 summary/trends;未进入的客户、供应商、网关、用户、角色、日志等接口不请求;进入对应页面后才按需请求。 |
|
||||
| 数据检查 | 首页指标全部来自真实 overview 响应,不使用 fixture 回退。 |
|
||||
| 安全检查 | 只请求当前账号具备权限的数据。 |
|
||||
|
||||
#### WEB-009 话单首屏性能与分页稳定性
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P0 |
|
||||
| 目的 | 验证话单首屏缩小到 25 条、列表和总数并行查询、同时间记录分页顺序稳定。 |
|
||||
| 前置条件 | 话单表存在超过 50 条记录,其中多条 `startedAt` 相同。 |
|
||||
| 步骤 | 1. 首次进入话单中心。2. 检查请求 take。3. 连续翻页并返回。4. 记录接口耗时并观察重复/遗漏。 |
|
||||
| 预期结果 | 默认 `take=25`;按 `startedAt desc, id desc` 稳定排序;列表与 count 并行;主列表仅展示核心字段,完整字段在详情抽屉。 |
|
||||
| 数据检查 | 前后页无重复或遗漏,total/hasMore 正确。 |
|
||||
| 安全检查 | 话单筛选和详情仍遵守 `cdrs.view` 权限。 |
|
||||
|
||||
### 8.9 性能、故障与安全
|
||||
|
||||
本节覆盖小规模并发、Worker/数据服务故障、Redis 热路径故障、SIP 安全探针、API 越权/重放和恢复性。执行故障类用例前必须确认回滚点和当前环境可中断。
|
||||
@@ -1905,6 +1941,40 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
|
||||
| 数据检查 | 迁移报告记录公网/私网 IP、端口、证书、备份路径和灰度 Call-ID。 |
|
||||
| 安全检查 | SSH 不对全网开放;Redis/MySQL/Prometheus/Grafana 不公网暴露;开发 CA 和本地路径不进入生产。 |
|
||||
|
||||
### 8.10 S57 页面布局、日期范围与号码库
|
||||
|
||||
#### WEB-010 页面内容顶部对齐
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P1 |
|
||||
| 步骤 | 登录后依次打开客户网关、用户、号码库以及数据量较少的配置页,检查标题、筛选区和列表面板。 |
|
||||
| 预期结果 | 所有区块按内容高度从顶部连续排列,不因视口剩余高度产生大面积空白或组件纵向拉伸。 |
|
||||
|
||||
#### ACT-004 B 本机 OpenSIPS MI
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P0 |
|
||||
| 步骤 | 1. 在 B 调用 OpenSIPS HTTP MI `dlg_list`。2. 打开当前通话。3. 发起测试呼叫后刷新。 |
|
||||
| 预期结果 | 无 A 服务器 SSH 依赖;空闲时快速返回空列表,呼叫中显示真实 dialog;MI 不可用时 3 秒内失败并显示受控提示。 |
|
||||
|
||||
#### DATE-001 三中心默认最近一周
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P1 |
|
||||
| 步骤 | 依次打开话单、质检、操作日志,检查日期输入和请求参数;修改范围查询,再点击重置。 |
|
||||
| 预期结果 | 首次和重置均为今天至前 6 天;开始日按 00:00:00、结束日按 23:59:59.999 查询;后端只返回范围内数据。 |
|
||||
|
||||
#### NUM-004 号码库全量与分页性能
|
||||
|
||||
| 字段 | 内容 |
|
||||
| --- | --- |
|
||||
| 优先级 | P0 |
|
||||
| 步骤 | 1. 导入器先干跑并确认未匹配为 0。2. 备份后导入。3. 核对四表数量。4. 四个页签执行首屏、翻页、页大小和筛选。 |
|
||||
| 预期结果 | 371 个行政城市、517,258 个有效七位号段、321 个区号、70 个前缀规则;首屏只读取 25 条;上一页/下一页与总数正确;页面加载不扫描或返回全表。 |
|
||||
|
||||
## 9. 缺陷分级
|
||||
|
||||
| 级别 | 定义 | 示例 |
|
||||
@@ -1929,7 +1999,7 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
|
||||
## 11. 当前已知风险与补充建议
|
||||
|
||||
- Redis 恢复后 OpenSIPS 热路径曾出现需要重启 `opensips` 才恢复的问题。上线前应补充 Redis 断连重连专项测试,并增加明确指标/告警。
|
||||
- 当前真实 80 万手机号段尚未导入。号码库性能、导入耗时、批量校验和 CDR 归属地准确性需单独做数据量级测试。
|
||||
- S57 号码库采用 517,258 条有效七位号段;归属和运营商字段是公开号段库口径,携号转网后的当前运营商不能仅凭该字段断言,仍需运营商实时数据源校验。
|
||||
- 真实 B 浏览器登录态下的逐页按钮、真实录音播放和 Nginx X-Accel 链路仍需人工复核。
|
||||
- 阿里云迁移前必须重跑网络安全组、正式 TLS、数据盘、备份恢复、故障注入和灰度呼叫,不应直接沿用本地 KVM 结论。
|
||||
- 建议后续增加 Playwright 前端冒烟:登录、菜单遍历、权限账号、关键表单、录音播放入口,降低页面空白类问题复发概率。
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# UI 与页面性能整改实施记录(2026-08-27)
|
||||
|
||||
## 实施范围
|
||||
|
||||
本次按《LisgloSIPS UI 性能整改方案》实施前端加载体验、按页面取数、Dashboard 请求合并、话单首屏性能、质检与号码库懒加载、宽表可读性和中文状态文案。TLS 证书按要求不在本次代码整改范围内,未修改证书或生产环境配置。
|
||||
|
||||
## 已完成
|
||||
|
||||
- 全局请求增加 10 秒超时和主动取消;切换菜单时取消上一页面请求,避免旧响应覆盖当前页面。
|
||||
- 页面请求超过 2 秒才显示加载弹窗,完成、失败或取消后立即隐藏;表格加载中显示骨架,不再与空状态同时出现。
|
||||
- 登录首页由全站 11 组接口预加载改为按当前路由加载;Dashboard 新增 `/api/v2/dashboard/overview`,服务端并行取得 summary 与 trends,前端只发一个首页概览请求。
|
||||
- 话单默认页大小由 100/50 降为 25;列表与总数改为并行查询,并增加 `startedAt desc, id desc` 稳定排序;主表精简为核心字段,完整信息保留在详情抽屉。
|
||||
- 质检录音默认限制为 50;抽检规则及客户/线路依赖只在打开规则抽屉时加载。
|
||||
- 号码库由首次同时加载四个页签改为只加载当前页签,其他页签首次打开时再读取。
|
||||
- 当前通话自动刷新改为静默更新;失败后停止自动轮询,避免 5 秒间隔持续叠加失败请求;手动首次加载仍使用延迟弹窗。
|
||||
- 宽表支持横向滚动、右侧操作列固定、长文本自动断行;长列表启用浏览器内容可见性优化。
|
||||
- Dashboard 未接入指标明确显示“等待真实数据”,不再使用开发 fixture 伪装实时数据;操作向文案已做中文化。
|
||||
|
||||
## S57 补充整改
|
||||
|
||||
- 全局 `.page-content` 固定为顶部对齐和内容高度网格行,消除数据较少时标题、筛选区和列表面板被拉伸形成的大面积空白;分页和日期范围控件收敛为全局组件和样式。
|
||||
- 当前通话不再依赖 B 到 A 的 SSH 包装命令,API 改为请求 B 本机 `http://127.0.0.1:8888/mi` 的 OpenSIPS JSON-RPC;保留 SSH 模式作为显式回滚选项。
|
||||
- 话单中心、质检中心、操作日志统一默认查询最近 7 个自然日(含今天),结束日期覆盖至当天 23:59:59.999;重置后仍恢复最近一周,不再回到无限时间范围。
|
||||
- 质检录音和操作日志补齐后端日期过滤,操作日志查询由“只对首批 100 条做浏览器过滤”改为日期、模块、结果的服务端过滤。
|
||||
- Dashboard 删除“客户消费 TOP 10”和“失败响应码分布”两个模块。
|
||||
- 号码库默认每页 25 条,四个页签分别维护分页游标、页大小和总数,只请求当前页;增加可重复执行的数据导入脚本,导入时保存来源和批次并拒绝部分匹配。
|
||||
|
||||
## 号码库数据口径
|
||||
|
||||
- 行政区划:国家统计局 2023-06-30 区划代码的 2024 整理版本;包含地级行政区和省/自治区直辖县级市,共 371 条。
|
||||
- 手机号段:`phone.dat` 2502 版本,共 517,258 条有效七位号段;运营商信息受携号转网影响,只表示号段原始分配归属,不作为当前在网运营商的强校验。
|
||||
- 城市区号:由号段库归属记录按城市多数匹配生成 321 条;当前表结构一个区号只能关联一个城市,共享区号按数据中出现最多的城市保存。
|
||||
- 运营商前缀:由七位号段按前三位多数归属生成 70 条,业务判定仍应优先使用七位号段。
|
||||
- 导入器要求所有有效号段均能映射到行政区代码;本批次干跑结果为未匹配 0 条。生产导入前必须先运行 MySQL/Redis 备份。
|
||||
|
||||
## S56 时暂未直接实施(现状更新)
|
||||
|
||||
- 未新增数据库索引。整改方案要求先以生产库 `EXPLAIN ANALYZE` 或等价执行计划确认慢点;当前没有获得生产数据库只读诊断授权和执行计划,直接增加索引可能放大写入成本。
|
||||
- 未修改 TLS 证书、Nginx 或生产部署配置。
|
||||
- S57 发布结果、生产数量和浏览器证据记录在对应发布报告中。
|
||||
|
||||
## 本地验证
|
||||
|
||||
- ESLint:通过。
|
||||
- TypeScript project build/typecheck:通过。
|
||||
- Vitest:27 个测试文件、98 个测试全部通过。
|
||||
- Web production build:通过,生成 `dist/index.html`、CSS 和 JS hash 产物。
|
||||
- 浏览器验收:已在 B 的真实登录态逐页检查 Dashboard、客户网关、当前通话、号码库、话单、质检和操作日志;生产结果和接口耗时见 `20260827_S57_UI_NUMBER_LIBRARY_RELEASE_REPORT.md`。
|
||||
@@ -8,37 +8,58 @@ fi
|
||||
echo "== LisgloSIPS B startup =="
|
||||
hostname
|
||||
|
||||
systemctl start \
|
||||
mysql \
|
||||
redis-server \
|
||||
nginx \
|
||||
heplify-server \
|
||||
lisglosips-prometheus \
|
||||
grafana-server
|
||||
systemctl start lisglosips.target
|
||||
|
||||
systemctl start \
|
||||
lisglosips@api \
|
||||
lisglosips@cdr-worker \
|
||||
lisglosips@recording-worker \
|
||||
required_services=(
|
||||
tailscaled
|
||||
mysql
|
||||
redis-server
|
||||
postgresql
|
||||
nginx
|
||||
opensips
|
||||
rtpengine-daemon
|
||||
rtpengine-recording-daemon
|
||||
lisglosips-redis-auth-proxy
|
||||
lisglosips@api
|
||||
lisglosips@cdr-worker
|
||||
lisglosips@recording-worker
|
||||
lisglosips@config-publisher
|
||||
heplify-server
|
||||
homer-app
|
||||
lisglosips-prometheus
|
||||
grafana-server
|
||||
lisglosips-node-exporter
|
||||
lisglosips-mysqld-exporter
|
||||
lisglosips-postgres-exporter
|
||||
lisglosips-redis-exporter
|
||||
)
|
||||
|
||||
for attempt in $(seq 1 30); do
|
||||
missing=()
|
||||
for unit in "${required_services[@]}"; do
|
||||
systemctl --quiet is-active "$unit" || missing+=("$unit")
|
||||
done
|
||||
[ "${#missing[@]}" -eq 0 ] && break
|
||||
[ "$attempt" -eq 30 ] && {
|
||||
printf 'startup timeout; inactive services: %s\n' "${missing[*]}" >&2
|
||||
exit 1
|
||||
}
|
||||
sleep 2
|
||||
done
|
||||
|
||||
nginx -t
|
||||
opensips -C -f /etc/opensips/opensips.cfg >/dev/null
|
||||
|
||||
echo "== release =="
|
||||
readlink -f /opt/lisglosips/current
|
||||
|
||||
echo "== services =="
|
||||
systemctl is-active \
|
||||
mysql \
|
||||
redis-server \
|
||||
nginx \
|
||||
lisglosips@api \
|
||||
lisglosips@cdr-worker \
|
||||
lisglosips@recording-worker \
|
||||
lisglosips@config-publisher \
|
||||
heplify-server \
|
||||
lisglosips-prometheus \
|
||||
grafana-server
|
||||
systemctl is-active "${required_services[@]}"
|
||||
|
||||
echo "== timers =="
|
||||
systemctl is-active lisglosips-backup.timer lisglosips-recording-finalize.timer
|
||||
systemctl is-enabled lisglosips.target lisglosips-backup.timer lisglosips-recording-finalize.timer \
|
||||
lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher
|
||||
|
||||
echo "== api ready =="
|
||||
curl -fsS http://127.0.0.1:3000/api/v2/health/ready
|
||||
@@ -49,4 +70,3 @@ echo "== preflight =="
|
||||
|
||||
echo "== failed units =="
|
||||
systemctl --failed --no-pager
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
commit=${1:?commit is required}
|
||||
archive=${2:?archive path is required}
|
||||
expected_sha=${3:?archive sha256 is required}
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
backup=/var/backups/lisglosips-s56/$stamp
|
||||
old=$(readlink -f /opt/lisglosips/current)
|
||||
new=/opt/lisglosips/releases/s56-b-consolidation-$stamp
|
||||
|
||||
echo "phase=verify-upload"
|
||||
test "$(sha256sum "$archive" | awk '{print $1}')" = "$expected_sha"
|
||||
test -d "$old"
|
||||
test ! -e "$new"
|
||||
|
||||
echo "phase=data-backup"
|
||||
systemctl start lisglosips-backup.service
|
||||
if systemctl --quiet is-failed lisglosips-backup.service; then
|
||||
systemctl status --no-pager lisglosips-backup.service
|
||||
exit 1
|
||||
fi
|
||||
find /data/backups/mysql -mindepth 1 -maxdepth 1 -type d | sort | tail -1
|
||||
find /data/backups/redis -mindepth 1 -maxdepth 1 -type d | sort | tail -1
|
||||
|
||||
echo "phase=config-backup"
|
||||
install -d -m 0700 "$backup"
|
||||
cp -a /etc/lisglosips/recording-worker.env "$backup/recording-worker.env"
|
||||
cp -a /etc/systemd/system/lisglosips@recording-worker.service.d/10-s28-recording.conf "$backup/10-s28-recording.conf"
|
||||
printf '%s\n' "$old" > "$backup/previous-release"
|
||||
[ ! -e /usr/local/sbin/lisglosips-recording-finalize ] || cp -a /usr/local/sbin/lisglosips-recording-finalize "$backup/finalizer.previous"
|
||||
[ ! -e /etc/systemd/system/lisglosips-recording-finalize.service ] || cp -a /etc/systemd/system/lisglosips-recording-finalize.service "$backup/finalizer-service.previous"
|
||||
[ ! -e /etc/systemd/system/lisglosips-recording-finalize.timer ] || cp -a /etc/systemd/system/lisglosips-recording-finalize.timer "$backup/finalizer-timer.previous"
|
||||
|
||||
changed=0
|
||||
rollback() {
|
||||
rc=$?
|
||||
if [ "$changed" -eq 1 ]; then
|
||||
echo "phase=rollback rc=$rc"
|
||||
systemctl disable --now lisglosips-recording-finalize.timer 2>/dev/null || true
|
||||
cp -a "$backup/recording-worker.env" /etc/lisglosips/recording-worker.env
|
||||
cp -a "$backup/10-s28-recording.conf" /etc/systemd/system/lisglosips@recording-worker.service.d/10-s28-recording.conf
|
||||
ln -sfn "$old" /opt/lisglosips/current
|
||||
systemctl daemon-reload
|
||||
systemctl restart lisglosips@recording-worker || true
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
echo "phase=stage-release"
|
||||
cp -a --reflink=auto "$old" "$new"
|
||||
tar -xzf "$archive" -C "$new"
|
||||
printf '%s\n' "$commit" > "$new/.deployed-commit"
|
||||
printf '%s\n' "$old" > "$new/.delta-base-release"
|
||||
chown -R root:lisglosips "$new/apps/worker-recording/dist" "$new/infra/server-b/s56"
|
||||
chmod -R u=rwX,g=rX,o= "$new/apps/worker-recording/dist" "$new/infra/server-b/s56"
|
||||
chmod 0755 "$new/infra/server-b/s56/scripts/lisglosips-recording-finalize"
|
||||
|
||||
echo "phase=install-local-recording"
|
||||
install -o root -g root -m 0755 "$new/infra/server-b/s56/scripts/lisglosips-recording-finalize" /usr/local/sbin/lisglosips-recording-finalize
|
||||
install -o root -g root -m 0644 "$new/infra/server-b/s56/systemd/lisglosips-recording-finalize.service" /etc/systemd/system/lisglosips-recording-finalize.service
|
||||
install -o root -g root -m 0644 "$new/infra/server-b/s56/systemd/lisglosips-recording-finalize.timer" /etc/systemd/system/lisglosips-recording-finalize.timer
|
||||
install -o root -g root -m 0644 "$new/infra/server-b/s56/systemd/10-local-recording.conf" /etc/systemd/system/lisglosips@recording-worker.service.d/10-s28-recording.conf
|
||||
|
||||
tmp_env=$(mktemp)
|
||||
grep -Ev '^(RECORDING_SOURCE_MODE|RECORDING_LOCAL_READY_DIR|RECORDING_REMOTE_HOST|RECORDING_SSH_CONFIG|RECORDING_REMOTE_READY_DIR)=' /etc/lisglosips/recording-worker.env > "$tmp_env"
|
||||
printf '%s\n' 'RECORDING_SOURCE_MODE=local' 'RECORDING_LOCAL_READY_DIR=/dev/shm/voip_rec/ready' >> "$tmp_env"
|
||||
install -o root -g lisglosips -m 0640 "$tmp_env" /etc/lisglosips/recording-worker.env
|
||||
rm -f "$tmp_env"
|
||||
install -d -o rtpengine -g rtpengine -m 0750 /dev/shm/voip_rec /dev/shm/voip_rec/incoming /dev/shm/voip_rec/ready /dev/shm/voip_rec/failed
|
||||
|
||||
changed=1
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now lisglosips-recording-finalize.timer
|
||||
systemctl start lisglosips-recording-finalize.service
|
||||
ln -sfn "$new" /opt/lisglosips/current
|
||||
systemctl restart lisglosips@recording-worker
|
||||
sleep 12
|
||||
|
||||
echo "phase=verify"
|
||||
test "$(readlink -f /opt/lisglosips/current)" = "$new"
|
||||
test "$(cat /opt/lisglosips/current/.deployed-commit)" = "$commit"
|
||||
test "$(systemctl is-active lisglosips-recording-finalize.timer)" = active
|
||||
test "$(systemctl is-active rtpengine-recording-daemon)" = active
|
||||
test "$(systemctl is-active lisglosips@recording-worker)" = active
|
||||
/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
|
||||
journalctl -u lisglosips@recording-worker --since "@$(( $(date +%s) - 60 ))" --no-pager | tail -30
|
||||
echo "ENV_MODE"
|
||||
grep -E '^(RECORDING_SOURCE_MODE|RECORDING_LOCAL_READY_DIR|RECORDING_LOCAL_ROOT|RECORDING_DELETE_SOURCE_AFTER_COPY)=' /etc/lisglosips/recording-worker.env
|
||||
echo "PROCESS_CHECK"
|
||||
worker_pid=$(systemctl show -p MainPID --value lisglosips@recording-worker)
|
||||
ps -o pid,ppid,comm,args --forest -g "$(ps -o sid= -p "$worker_pid" | tr -d ' ')" || true
|
||||
pgrep -a ssh || true
|
||||
printf 'BACKUP=%s\nRELEASE=%s\nCOMMIT=%s\n' "$backup" "$new" "$commit"
|
||||
|
||||
changed=0
|
||||
trap - ERR
|
||||
@@ -0,0 +1,10 @@
|
||||
LISGLOSIPS_ENTRYPOINT=apps/worker-recording/dist/main.js
|
||||
LISGLOSIPS_SERVICE_NAME=worker-recording
|
||||
LISGLOSIPS_LOG_LEVEL=info
|
||||
DATABASE_URL=mysql://user:password@127.0.0.1:3306/lisglosips
|
||||
RECORDING_SOURCE_MODE=local
|
||||
RECORDING_LOCAL_READY_DIR=/dev/shm/voip_rec/ready
|
||||
RECORDING_LOCAL_ROOT=/data/recordings
|
||||
RECORDING_SCAN_INTERVAL_MS=10000
|
||||
RECORDING_MAX_FILES_PER_SCAN=50
|
||||
RECORDING_DELETE_SOURCE_AFTER_COPY=true
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_DIR=${1:-/dev/shm/voip_rec}
|
||||
INCOMING_DIR="$BASE_DIR/incoming"
|
||||
READY_DIR="$BASE_DIR/ready"
|
||||
FAILED_DIR="$BASE_DIR/failed"
|
||||
MIN_AGE_SECONDS=${MIN_AGE_SECONDS:-15}
|
||||
|
||||
mkdir -p "$INCOMING_DIR" "$READY_DIR" "$FAILED_DIR"
|
||||
chmod 0750 "$BASE_DIR" "$INCOMING_DIR" "$READY_DIR" "$FAILED_DIR"
|
||||
|
||||
find "$INCOMING_DIR" -type f \( -name '*.wav' -o -name '*.mp3' \) -print0 |
|
||||
while IFS= read -r -d '' source; do
|
||||
if [ ! -s "$source" ]; then
|
||||
continue
|
||||
fi
|
||||
now=$(date +%s)
|
||||
mtime=$(stat -c %Y "$source")
|
||||
if [ $((now - mtime)) -lt "$MIN_AGE_SECONDS" ]; then
|
||||
continue
|
||||
fi
|
||||
if command -v fuser >/dev/null 2>&1 && fuser -s "$source"; then
|
||||
continue
|
||||
fi
|
||||
rel=${source#"$INCOMING_DIR"/}
|
||||
part="$READY_DIR/$rel.part"
|
||||
ready="$READY_DIR/$rel.ready"
|
||||
failed="$FAILED_DIR/$rel"
|
||||
mkdir -p "$(dirname "$part")" "$(dirname "$failed")"
|
||||
chmod 0750 "$(dirname "$part")" "$(dirname "$failed")"
|
||||
if [ -e "$ready" ] || [ -e "$part" ]; then
|
||||
mv -- "$source" "$failed.$(date -u +%Y%m%dT%H%M%SZ).duplicate"
|
||||
continue
|
||||
fi
|
||||
mv -- "$source" "$part"
|
||||
chmod 0640 "$part"
|
||||
mv -- "$part" "$ready"
|
||||
done
|
||||
@@ -0,0 +1,6 @@
|
||||
[Service]
|
||||
User=lisglo-recorder
|
||||
Group=lisglosips
|
||||
SupplementaryGroups=rtpengine
|
||||
BindPaths=/dev/shm/voip_rec/ready
|
||||
ReadWritePaths=/data/recordings /dev/shm/voip_rec/ready /run/lisglosips /run/lisglo-recorder
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Finalize local RTPEngine recordings for LisgloSIPS
|
||||
After=rtpengine-recording-daemon.service
|
||||
Requires=rtpengine-recording-daemon.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=rtpengine
|
||||
Group=rtpengine
|
||||
ExecStart=/usr/local/sbin/lisglosips-recording-finalize /dev/shm/voip_rec
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/dev/shm/voip_rec
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Finalize local RTPEngine recordings every five seconds
|
||||
|
||||
[Timer]
|
||||
OnBootSec=10s
|
||||
OnUnitActiveSec=5s
|
||||
AccuracySec=1s
|
||||
Unit=lisglosips-recording-finalize.service
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
commit=${1:?commit is required}
|
||||
archive=${2:?archive path is required}
|
||||
expected_sha=${3:?archive sha256 is required}
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
backup=/var/backups/lisglosips-s57/$stamp
|
||||
old=$(readlink -f /opt/lisglosips/current)
|
||||
new=/opt/lisglosips/releases/s57-ui-number-library-$stamp
|
||||
|
||||
test "$(sha256sum "$archive" | awk '{print $1}')" = "$expected_sha"
|
||||
test -d "$old"
|
||||
test ! -e "$new"
|
||||
|
||||
echo "phase=data-backup"
|
||||
systemctl start lisglosips-backup.service
|
||||
if systemctl --quiet is-failed lisglosips-backup.service; then
|
||||
systemctl status --no-pager lisglosips-backup.service
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "phase=config-backup"
|
||||
install -d -m 0700 "$backup"
|
||||
cp -a /etc/lisglosips/api.env "$backup/api.env"
|
||||
printf '%s\n' "$old" > "$backup/previous-release"
|
||||
|
||||
changed=0
|
||||
rollback() {
|
||||
rc=$?
|
||||
if [ "$changed" -eq 1 ]; then
|
||||
echo "phase=rollback rc=$rc"
|
||||
cp -a "$backup/api.env" /etc/lisglosips/api.env
|
||||
ln -sfn "$old" /opt/lisglosips/current
|
||||
systemctl restart lisglosips@api || true
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
echo "phase=stage-release"
|
||||
cp -a --reflink=auto "$old" "$new"
|
||||
tar -xzf "$archive" -C "$new"
|
||||
printf '%s\n' "$commit" > "$new/.deployed-commit"
|
||||
printf '%s\n' "$old" > "$new/.delta-base-release"
|
||||
chown -R root:lisglosips "$new/apps/api/dist" "$new/public" "$new/scripts" "$new/infra/server-b/s57"
|
||||
chmod -R u=rwX,g=rX,o= "$new/apps/api/dist" "$new/public" "$new/scripts" "$new/infra/server-b/s57"
|
||||
chmod 0755 "$new/infra/server-b/s57/deploy-ui-number-library.sh"
|
||||
|
||||
tmp_env=$(mktemp)
|
||||
grep -Ev '^(ACTIVE_CALLS_MODE|ACTIVE_CALLS_HTTP_URL|ACTIVE_CALLS_TIMEOUT_MS)=' /etc/lisglosips/api.env > "$tmp_env"
|
||||
printf '%s\n' 'ACTIVE_CALLS_MODE=http' 'ACTIVE_CALLS_HTTP_URL=http://127.0.0.1:8888/mi' 'ACTIVE_CALLS_TIMEOUT_MS=3000' >> "$tmp_env"
|
||||
install -o root -g lisglosips -m 0640 "$tmp_env" /etc/lisglosips/api.env
|
||||
rm -f "$tmp_env"
|
||||
|
||||
changed=1
|
||||
ln -sfn "$new" /opt/lisglosips/current
|
||||
systemctl restart lisglosips@api
|
||||
sleep 4
|
||||
|
||||
echo "phase=verify"
|
||||
test "$(readlink -f /opt/lisglosips/current)" = "$new"
|
||||
test "$(cat /opt/lisglosips/current/.deployed-commit)" = "$commit"
|
||||
test "$(systemctl is-active lisglosips@api)" = active
|
||||
curl --fail --silent --show-error http://127.0.0.1:3000/api/v2/health/ready >/dev/null
|
||||
curl --fail --silent --show-error -H 'content-type: application/json' \
|
||||
--data '{"jsonrpc":"2.0","id":1,"method":"dlg_list","params":[]}' \
|
||||
http://127.0.0.1:8888/mi >/dev/null
|
||||
/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
|
||||
printf 'BACKUP=%s\nRELEASE=%s\nCOMMIT=%s\n' "$backup" "$new" "$commit"
|
||||
|
||||
changed=0
|
||||
trap - ERR
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
release_root=${1:-/opt/lisglosips/current}
|
||||
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
backup=/var/backups/lisglosips-s58/$stamp
|
||||
|
||||
test -x "$release_root/infra/ops/startup/lisglosips-start-b.sh"
|
||||
test -f "$release_root/infra/server-b/s58/systemd/lisglosips.target"
|
||||
test -f "$release_root/infra/server-b/s58/systemd/lisglosips-boot-check.service"
|
||||
|
||||
install -d -m 0700 "$backup"
|
||||
for path in \
|
||||
/usr/local/sbin/lisglosips-start-b \
|
||||
/etc/systemd/system/lisglosips.target \
|
||||
/etc/systemd/system/lisglosips-boot-check.service; do
|
||||
[ ! -e "$path" ] || cp -a "$path" "$backup/$(basename "$path").previous"
|
||||
done
|
||||
|
||||
changed=0
|
||||
rollback() {
|
||||
rc=$?
|
||||
if [ "$changed" -eq 1 ]; then
|
||||
echo "phase=rollback rc=$rc"
|
||||
systemctl disable lisglosips-boot-check.service lisglosips.target \
|
||||
lisglosips@recording-worker.service lisglosips@config-publisher.service 2>/dev/null || true
|
||||
for name in lisglosips-start-b lisglosips.target lisglosips-boot-check.service; do
|
||||
previous="$backup/$name.previous"
|
||||
case "$name" in
|
||||
lisglosips-start-b) target=/usr/local/sbin/lisglosips-start-b ;;
|
||||
*) target=/etc/systemd/system/$name ;;
|
||||
esac
|
||||
if [ -e "$previous" ]; then cp -a "$previous" "$target"; else rm -f "$target"; fi
|
||||
done
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
echo "phase=install"
|
||||
install -o root -g root -m 0755 "$release_root/infra/ops/startup/lisglosips-start-b.sh" /usr/local/sbin/lisglosips-start-b
|
||||
install -o root -g root -m 0644 "$release_root/infra/server-b/s58/systemd/lisglosips.target" /etc/systemd/system/lisglosips.target
|
||||
install -o root -g root -m 0644 "$release_root/infra/server-b/s58/systemd/lisglosips-boot-check.service" /etc/systemd/system/lisglosips-boot-check.service
|
||||
|
||||
changed=1
|
||||
systemctl daemon-reload
|
||||
systemctl enable lisglosips.target lisglosips-boot-check.service \
|
||||
lisglosips@api.service lisglosips@cdr-worker.service \
|
||||
lisglosips@recording-worker.service lisglosips@config-publisher.service
|
||||
|
||||
echo "phase=start-and-verify"
|
||||
systemctl start lisglosips.target
|
||||
systemctl restart lisglosips-boot-check.service
|
||||
test "$(systemctl is-enabled lisglosips.target)" = enabled
|
||||
test "$(systemctl is-enabled lisglosips@recording-worker.service)" = enabled
|
||||
test "$(systemctl is-enabled lisglosips@config-publisher.service)" = enabled
|
||||
test "$(systemctl is-active lisglosips@recording-worker.service)" = active
|
||||
test "$(systemctl is-active lisglosips@config-publisher.service)" = active
|
||||
|
||||
printf 'BACKUP=%s\nTARGET=%s\n' "$backup" "$(systemctl is-enabled lisglosips.target)"
|
||||
changed=0
|
||||
trap - ERR
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Validate LisgloSIPS after Server B boot
|
||||
Requires=lisglosips.target
|
||||
After=lisglosips.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/lisglosips-start-b
|
||||
RemainAfterExit=yes
|
||||
TimeoutStartSec=90
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=LisgloSIPS single-server stack on Server B
|
||||
Wants=network-online.target tailscaled.service
|
||||
Wants=mysql.service redis-server.service postgresql.service
|
||||
Wants=nginx.service opensips.service rtpengine-daemon.service rtpengine-recording-daemon.service
|
||||
Wants=lisglosips-redis-auth-proxy.service lisglosips-redis-hotpath-load.service
|
||||
Wants=lisglosips@api.service lisglosips@cdr-worker.service lisglosips@recording-worker.service lisglosips@config-publisher.service
|
||||
Wants=heplify-server.service homer-app.service
|
||||
Wants=lisglosips-prometheus.service grafana-server.service
|
||||
Wants=lisglosips-node-exporter.service lisglosips-mysqld-exporter.service lisglosips-postgres-exporter.service lisglosips-redis-exporter.service
|
||||
Wants=lisglosips-backup.timer lisglosips-recording-finalize.timer
|
||||
After=network-online.target tailscaled.service
|
||||
After=mysql.service redis-server.service postgresql.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -71,8 +71,13 @@ const copyEntries = [
|
||||
['packages/redis/dist', 'packages/redis/dist', true],
|
||||
['packages/redis/node_modules', 'packages/redis/node_modules', true],
|
||||
['infra/server-b/s30', 'infra/server-b/s30', true],
|
||||
['infra/server-b/s56', 'infra/server-b/s56', true],
|
||||
['infra/server-b/s57', 'infra/server-b/s57', true],
|
||||
['infra/server-b/s58', 'infra/server-b/s58', true],
|
||||
['infra/ops/startup/lisglosips-start-b.sh', 'infra/ops/startup/lisglosips-start-b.sh', true],
|
||||
['infra/server-a/s28/lisglosips_hotpath.lua', 'infra/server-a/s28/lisglosips_hotpath.lua', true],
|
||||
['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true]
|
||||
['scripts/phase2-gateway-migration.mjs', 'scripts/phase2-gateway-migration.mjs', true],
|
||||
['scripts/import-number-library.mjs', 'scripts/import-number-library.mjs', true]
|
||||
];
|
||||
|
||||
function run(command, commandArgs, options = {}) {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import fs from 'node:fs';
|
||||
import zlib from 'node:zlib';
|
||||
import crypto from 'node:crypto';
|
||||
import { PrismaClient } from '../packages/database/dist/index.js';
|
||||
|
||||
const args = Object.fromEntries(process.argv.slice(2).map((entry) => {
|
||||
const [key, ...value] = entry.replace(/^--/, '').split('=');
|
||||
return [key, value.length ? value.join('=') : true];
|
||||
}));
|
||||
|
||||
if (!args.admin || !args.phone) {
|
||||
throw new Error('Usage: node scripts/import-number-library.mjs --admin=/path/area.json.gz --phone=/path/phone.dat [--dry-run]');
|
||||
}
|
||||
|
||||
const batchId = String(args.batch || 'number-library-20260827');
|
||||
const adminSource = '国家统计局区划代码整理库 2024 (2023-06-30)';
|
||||
const phoneSource = 'pangongzi/phone phone.dat 2025-02';
|
||||
const adminTree = JSON.parse(zlib.gunzipSync(fs.readFileSync(String(args.admin))).toString('utf8'));
|
||||
|
||||
const sixDigitCode = (code) => String(code).slice(0, 6);
|
||||
const normalize = (value) => String(value || '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/特别行政区|维吾尔自治区|壮族自治区|回族自治区|自治区|土家族苗族自治州|苗族侗族自治州|藏族自治州|蒙古族藏族自治州|柯尔克孜自治州|哈萨克自治州|自治州|省|市|地区|盟|县$/g, '');
|
||||
|
||||
const cities = [];
|
||||
for (const province of adminTree) {
|
||||
for (const city of province.children || []) {
|
||||
const base = {
|
||||
code: sixDigitCode(city.code),
|
||||
provinceCode: sixDigitCode(province.code),
|
||||
provinceName: province.name,
|
||||
cityCode: sixDigitCode(city.code),
|
||||
cityName: city.name === '市辖区' ? province.name : city.name,
|
||||
cityLevel: 'PREFECTURE',
|
||||
status: 'ENABLED'
|
||||
};
|
||||
cities.push(base);
|
||||
if (/直辖县级行政区划/.test(city.name)) {
|
||||
cities.pop();
|
||||
for (const countyCity of city.children || []) {
|
||||
cities.push({ ...base, code: sixDigitCode(countyCity.code), cityCode: sixDigitCode(countyCity.code), cityName: countyCity.name, cityLevel: 'COUNTY_DIRECT' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const phoneBuffer = fs.readFileSync(String(args.phone));
|
||||
const indexOffset = phoneBuffer.readUInt32LE(4);
|
||||
const phoneRecords = [];
|
||||
for (let offset = indexOffset; offset + 9 <= phoneBuffer.length; offset += 9) {
|
||||
const segment7 = String(phoneBuffer.readUInt32LE(offset)).padStart(7, '0');
|
||||
const dataOffset = phoneBuffer.readUInt32LE(offset + 4);
|
||||
const end = phoneBuffer.indexOf(0, dataOffset);
|
||||
const [provinceName, cityName, zipCode, areaCode] = phoneBuffer.subarray(dataOffset, end).toString('utf8').split('|');
|
||||
phoneRecords.push({ segment7, provinceName, cityName, zipCode, areaCode, type: phoneBuffer[offset + 8] });
|
||||
}
|
||||
|
||||
const byProvince = new Map();
|
||||
for (const city of cities) {
|
||||
const key = normalize(city.provinceName);
|
||||
const values = byProvince.get(key) || [];
|
||||
values.push(city);
|
||||
byProvince.set(key, values);
|
||||
}
|
||||
|
||||
function findCity(record) {
|
||||
const provinceCities = byProvince.get(normalize(record.provinceName)) || [];
|
||||
const aliasKey = `${normalize(record.provinceName)}|${normalize(record.cityName)}`;
|
||||
const cityAliases = {
|
||||
'山东|莱芜': '济南',
|
||||
'新疆|巴州': '巴音郭楞',
|
||||
'新疆|博州': '博尔塔拉',
|
||||
'新疆|克州': '克孜勒苏',
|
||||
'新疆|奎屯': '伊犁',
|
||||
'青海|格尔木': '海西'
|
||||
};
|
||||
const cityName = cityAliases[aliasKey] || normalize(record.cityName || record.provinceName);
|
||||
const exact = provinceCities.find((city) => normalize(city.cityName) === cityName);
|
||||
if (exact) return exact;
|
||||
const partial = provinceCities.filter((city) => normalize(city.cityName).startsWith(cityName) || cityName.startsWith(normalize(city.cityName)));
|
||||
return partial.length === 1 ? partial[0] : provinceCities.length === 1 ? provinceCities[0] : null;
|
||||
}
|
||||
|
||||
const directMatches = phoneRecords.map((record) => ({ record, city: findCity(record) }));
|
||||
const areaVotes = new Map();
|
||||
for (const { record, city } of directMatches) {
|
||||
if (!city || !record.areaCode) continue;
|
||||
const key = `${record.areaCode}|${city.cityCode}`;
|
||||
areaVotes.set(key, (areaVotes.get(key) || 0) + 1);
|
||||
}
|
||||
const areaCity = new Map();
|
||||
for (const [key, count] of areaVotes) {
|
||||
const [areaCode, cityCode] = key.split('|');
|
||||
const current = areaCity.get(areaCode);
|
||||
if (!current || count > current.count) areaCity.set(areaCode, { cityCode, count });
|
||||
}
|
||||
const cityByCode = new Map(cities.map((city) => [city.cityCode, city]));
|
||||
const unmatched = [];
|
||||
const carrierMap = { 1: 'MOBILE', 2: 'UNICOM', 3: 'TELECOM', 4: 'MVNO', 5: 'MVNO', 6: 'MVNO', 7: 'BROADCAST', 8: 'MVNO' };
|
||||
const segments = [];
|
||||
for (const match of directMatches) {
|
||||
if (!/^1\d{6}$/.test(match.record.segment7)) continue;
|
||||
const city = match.city || cityByCode.get(areaCity.get(match.record.areaCode)?.cityCode);
|
||||
if (!city) {
|
||||
unmatched.push(match.record);
|
||||
continue;
|
||||
}
|
||||
segments.push({
|
||||
segment7: match.record.segment7,
|
||||
cityCode: city.cityCode,
|
||||
provinceName: city.provinceName,
|
||||
cityName: city.cityName,
|
||||
carrier: carrierMap[match.record.type] || 'UNKNOWN',
|
||||
source: phoneSource,
|
||||
batchId
|
||||
});
|
||||
}
|
||||
|
||||
const areaCodes = [...areaCity.entries()].map(([areaCode, vote]) => {
|
||||
const city = cityByCode.get(vote.cityCode);
|
||||
return { areaCode, cityCode: city.cityCode, provinceName: city.provinceName, cityName: city.cityName, source: phoneSource, batchId };
|
||||
}).filter((item) => /^0\d{2,3}$/.test(item.areaCode));
|
||||
|
||||
const prefixVotes = new Map();
|
||||
for (const segment of segments) {
|
||||
const prefix = segment.segment7.slice(0, 3);
|
||||
const key = `${prefix}|${segment.carrier}`;
|
||||
prefixVotes.set(key, (prefixVotes.get(key) || 0) + 1);
|
||||
}
|
||||
const prefixWinners = new Map();
|
||||
for (const [key, count] of prefixVotes) {
|
||||
const [prefix, carrier] = key.split('|');
|
||||
const current = prefixWinners.get(prefix);
|
||||
if (!current || count > current.count) prefixWinners.set(prefix, { carrier, count });
|
||||
}
|
||||
const prefixes = [...prefixWinners.entries()].map(([prefix, value]) => ({ prefix, carrier: value.carrier, priority: 100, source: phoneSource, batchId }));
|
||||
|
||||
const report = {
|
||||
batchId,
|
||||
adminSource,
|
||||
phoneSource,
|
||||
phoneVersion: phoneBuffer.subarray(0, 4).toString('utf8'),
|
||||
cities: cities.length,
|
||||
sourcePhoneRecords: phoneRecords.length,
|
||||
segments: segments.length,
|
||||
unmatched: unmatched.length,
|
||||
areaCodes: areaCodes.length,
|
||||
prefixes: prefixes.length,
|
||||
unmatchedLocations: [...new Map(unmatched.map(({ provinceName, cityName, areaCode }) => [`${provinceName}|${cityName}|${areaCode}`, { provinceName, cityName, areaCode }])).values()]
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
|
||||
if (args['dry-run']) process.exit(0);
|
||||
if (unmatched.length) throw new Error(`Refusing partial import: ${unmatched.length} phone segments could not be mapped to an administrative city.`);
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const current = {
|
||||
cities: await prisma.geoCity.count(),
|
||||
segments: await prisma.phoneNumberSegment.count(),
|
||||
areaCodes: await prisma.phoneAreaCode.count(),
|
||||
prefixes: await prisma.carrierPrefixRule.count()
|
||||
};
|
||||
if (Object.values(current).some(Boolean)) throw new Error(`Number library is not empty: ${JSON.stringify(current)}`);
|
||||
|
||||
await prisma.geoCity.createMany({ data: cities, skipDuplicates: true });
|
||||
for (let offset = 0; offset < segments.length; offset += 1000) {
|
||||
await prisma.phoneNumberSegment.createMany({ data: segments.slice(offset, offset + 1000), skipDuplicates: true });
|
||||
}
|
||||
await prisma.phoneAreaCode.createMany({ data: areaCodes, skipDuplicates: true });
|
||||
await prisma.carrierPrefixRule.createMany({ data: prefixes, skipDuplicates: true });
|
||||
await prisma.outboxEvent.create({
|
||||
data: {
|
||||
id: `out_${crypto.randomUUID().replaceAll('-', '').slice(0, 28)}`,
|
||||
aggregateType: 'number_library_config',
|
||||
aggregateId: 'number-library',
|
||||
eventType: 'number_library.seeded',
|
||||
payload: report
|
||||
}
|
||||
});
|
||||
console.log(JSON.stringify({ imported: report }, null, 2));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Remote Smoke Test Report
|
||||
|
||||
Date: 2026-06-30T06:16:36.256Z
|
||||
Base URL: https://100.90.90.91
|
||||
Timeout: 30000ms
|
||||
Attempts: 2
|
||||
|
||||
| Result | Check | Detail |
|
||||
| --- | --- | --- |
|
||||
| PASS | / returns 200 | status=200, duration=5724ms, attempt=1 |
|
||||
| PASS | frontend HTML contains app root | contentType=text/html, bytes=466, attempt=1 |
|
||||
| PASS | /api/v2/health/live returns 200 | status=200, duration=596ms, attempt=1 |
|
||||
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T06:16:35.041Z","checks":{"process":"ok"}} |
|
||||
| PASS | /api/v2/health/ready returns 200 | status=200, duration=158ms, attempt=1 |
|
||||
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T06:16:35.202Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
|
||||
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1793ms, attempt=1 |
|
||||
| PASS | captcha endpoint returns id, image, and expiry | captchaId=df88f31f-3a39-4fc9-b60d-2ecda12f8446, expiresAt=2026-06-30T06:21:36.991Z, attempt=1 |
|
||||
|
||||
## Raw Endpoints
|
||||
|
||||
- /: status=200, duration=5724ms, attempt=1, contentType=text/html
|
||||
- /api/v2/health/live: status=200, duration=596ms, attempt=1, contentType=application/json; charset=utf-8
|
||||
- /api/v2/health/ready: status=200, duration=158ms, attempt=1, contentType=application/json; charset=utf-8
|
||||
- /api/v2/auth/captcha: status=200, duration=1793ms, attempt=1, contentType=application/json; charset=utf-8
|
||||
@@ -0,0 +1,24 @@
|
||||
# Remote Smoke Test Report
|
||||
|
||||
Date: 2026-08-27T07:39:55.055Z
|
||||
Base URL: https://100.90.90.91
|
||||
Timeout: 30000ms
|
||||
Attempts: 2
|
||||
|
||||
| Result | Check | Detail |
|
||||
| --- | --- | --- |
|
||||
| PASS | / returns 200 | status=200, duration=1373ms, attempt=1 |
|
||||
| PASS | frontend HTML contains app root | contentType=text/html, bytes=466, attempt=1 |
|
||||
| PASS | /api/v2/health/live returns 200 | status=200, duration=376ms, attempt=1 |
|
||||
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-08-27T07:39:54.555Z","checks":{"process":"ok"}} |
|
||||
| PASS | /api/v2/health/ready returns 200 | status=200, duration=377ms, attempt=1 |
|
||||
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-08-27T07:39:54.933Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
|
||||
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=389ms, attempt=1 |
|
||||
| PASS | captcha endpoint returns id, image, and expiry | captchaId=4a8cafd9-270b-4fe7-bd53-08e0d0e587ec, expiresAt=2026-08-27T07:44:55.308Z, attempt=1 |
|
||||
|
||||
## Raw Endpoints
|
||||
|
||||
- /: status=200, duration=1373ms, attempt=1, contentType=text/html
|
||||
- /api/v2/health/live: status=200, duration=376ms, attempt=1, contentType=application/json; charset=utf-8
|
||||
- /api/v2/health/ready: status=200, duration=377ms, attempt=1, contentType=application/json; charset=utf-8
|
||||
- /api/v2/auth/captcha: status=200, duration=389ms, attempt=1, contentType=application/json; charset=utf-8
|
||||
Reference in New Issue
Block a user