feat(workspace): 支持多行命令输入并新增仪表盘接口

将底部命令输入框改为支持自动增高的多行 textarea,
并把发送快捷键调整为 Ctrl+Shift+Enter,同时更新多语言提示文案

新增 dashboard summary 后端接口与聚合类型定义,
为首页管理驾驶舱改造提供统一数据入口,并同步知识库方案记录
This commit is contained in:
yinjianm
2026-03-25 23:57:17 +08:00
parent 9e49fcea61
commit 1f52ff6e0a
22 changed files with 1068 additions and 16 deletions
@@ -0,0 +1,19 @@
import { Request, Response } from 'express';
import { DashboardService } from './dashboard.service';
const dashboardService = new DashboardService();
export class DashboardController {
async getSummary(req: Request, res: Response): Promise<void> {
try {
const summary = await dashboardService.getSummary();
res.status(200).json(summary);
} catch (error: any) {
console.error('[DashboardController] 获取仪表盘统计失败:', error);
res.status(500).json({
message: '获取仪表盘统计失败',
error: error.message,
});
}
}
}
@@ -0,0 +1,247 @@
import { allDb, getDb, getDbInstance } from '../database/connection';
import type {
DashboardActionBreakdownItem,
DashboardActivityTrendPoint,
DashboardCountByType,
DashboardSummary,
DashboardTopConnection,
} from './dashboard.types';
import type { AuditLogActionType } from '../types/audit.types';
const DAY_IN_SECONDS = 24 * 60 * 60;
const DASHBOARD_WINDOW_DAYS = 7;
const SSH_SUCCESS_ACTION = 'SSH_CONNECT_SUCCESS';
const SSH_FAILURE_ACTIONS: AuditLogActionType[] = ['SSH_CONNECT_FAILURE', 'SSH_SHELL_FAILURE'];
const TOP_CONNECTION_ACTIONS: AuditLogActionType[] = [
SSH_SUCCESS_ACTION,
...SSH_FAILURE_ACTIONS,
];
const ACTION_BREAKDOWN_LIMIT = 6;
const TOP_CONNECTION_LIMIT = 5;
interface CountRow {
total: number;
}
interface CountByLabelRow {
label: string;
count: number;
}
interface TrendRow {
date: string;
count: number;
}
interface ConnectionLookupRow {
id: number;
name: string | null;
host: string;
}
interface AuditDetailRow {
timestamp: number;
details: string | null;
}
interface ParsedAuditDetails {
connectionId?: number;
connectionName?: string;
}
const buildDateWindow = (days: number): string[] => {
const result: string[] = [];
const now = new Date();
for (let index = days - 1; index >= 0; index -= 1) {
const date = new Date(now);
date.setUTCDate(date.getUTCDate() - index);
result.push(date.toISOString().slice(0, 10));
}
return result;
};
const safeParseAuditDetails = (raw: string | null): ParsedAuditDetails | null => {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as ParsedAuditDetails;
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (error) {
return null;
}
};
export const getDashboardSummary = async (): Promise<DashboardSummary> => {
const db = await getDbInstance();
const now = Math.floor(Date.now() / 1000);
const since7d = now - (DASHBOARD_WINDOW_DAYS - 1) * DAY_IN_SECONDS;
const since24h = now - DAY_IN_SECONDS;
const [
totalConnectionsRow,
activeConnectionsRow,
taggedConnectionsRow,
auditLogsRow,
sshOutcomeRows,
connectionTypeRows,
actionBreakdownRows,
trendRows,
topConnectionLogRows,
connectionLookupRows,
] = await Promise.all([
getDb<CountRow>(db, 'SELECT COUNT(*) as total FROM connections'),
getDb<CountRow>(db, 'SELECT COUNT(*) as total FROM connections WHERE last_connected_at >= ?', [since7d]),
getDb<CountRow>(db, 'SELECT COUNT(DISTINCT connection_id) as total FROM connection_tags'),
getDb<CountRow>(db, 'SELECT COUNT(*) as total FROM audit_logs'),
allDb<CountByLabelRow>(
db,
`
SELECT action_type as label, COUNT(*) as count
FROM audit_logs
WHERE timestamp >= ?
AND action_type IN (?, ?, ?)
GROUP BY action_type
`,
[since24h, SSH_SUCCESS_ACTION, ...SSH_FAILURE_ACTIONS],
),
allDb<CountByLabelRow>(
db,
`
SELECT type as label, COUNT(*) as count
FROM connections
GROUP BY type
ORDER BY count DESC, type ASC
`,
),
allDb<CountByLabelRow>(
db,
`
SELECT action_type as label, COUNT(*) as count
FROM audit_logs
WHERE timestamp >= ?
GROUP BY action_type
ORDER BY count DESC, action_type ASC
LIMIT ?
`,
[since7d, ACTION_BREAKDOWN_LIMIT],
),
allDb<TrendRow>(
db,
`
SELECT strftime('%Y-%m-%d', timestamp, 'unixepoch') as date, COUNT(*) as count
FROM audit_logs
WHERE timestamp >= ?
GROUP BY strftime('%Y-%m-%d', timestamp, 'unixepoch')
ORDER BY date ASC
`,
[since7d],
),
allDb<AuditDetailRow>(
db,
`
SELECT timestamp, details
FROM audit_logs
WHERE timestamp >= ?
AND action_type IN (?, ?, ?)
AND details IS NOT NULL
ORDER BY timestamp DESC
`,
[since7d, ...TOP_CONNECTION_ACTIONS],
),
allDb<ConnectionLookupRow>(
db,
'SELECT id, name, host FROM connections',
),
]);
const sshOutcomesMap = new Map<string, number>(
sshOutcomeRows.map((row) => [row.label, row.count]),
);
const connectionTypeMap = new Map<string, number>(
connectionTypeRows.map((row) => [row.label, row.count]),
);
const connectionTypes: DashboardCountByType[] = ['SSH', 'RDP', 'VNC'].map((type) => ({
label: type,
count: connectionTypeMap.get(type) ?? 0,
}));
const actionBreakdown7d: DashboardActionBreakdownItem[] = actionBreakdownRows.map((row) => ({
actionType: (row.label as AuditLogActionType) ?? 'OTHER',
count: row.count,
}));
const trendMap = new Map<string, number>(
trendRows.map((row) => [row.date, row.count]),
);
const activityTrend7d: DashboardActivityTrendPoint[] = buildDateWindow(DASHBOARD_WINDOW_DAYS).map((date) => ({
date,
count: trendMap.get(date) ?? 0,
}));
const connectionLookup = new Map<number, ConnectionLookupRow>(
connectionLookupRows.map((row) => [row.id, row]),
);
const topConnectionCounts = new Map<number, DashboardTopConnection>();
for (const row of topConnectionLogRows) {
const details = safeParseAuditDetails(row.details);
const connectionId = details?.connectionId;
if (typeof connectionId !== 'number' || Number.isNaN(connectionId)) {
continue;
}
const lookup = connectionLookup.get(connectionId);
const connectionName = details?.connectionName || lookup?.name || `#${connectionId}`;
const host = lookup?.host || '-';
const existing = topConnectionCounts.get(connectionId);
if (existing) {
existing.count += 1;
existing.lastSeenAt = Math.max(existing.lastSeenAt, row.timestamp);
continue;
}
topConnectionCounts.set(connectionId, {
connectionId,
connectionName,
host,
count: 1,
lastSeenAt: row.timestamp,
});
}
const topConnections = Array.from(topConnectionCounts.values())
.sort((left, right) => {
if (right.count !== left.count) {
return right.count - left.count;
}
return right.lastSeenAt - left.lastSeenAt;
})
.slice(0, TOP_CONNECTION_LIMIT);
return {
totals: {
connections: totalConnectionsRow?.total ?? 0,
activeConnections7d: activeConnectionsRow?.total ?? 0,
taggedConnections: taggedConnectionsRow?.total ?? 0,
auditLogs: auditLogsRow?.total ?? 0,
},
sshOutcomes24h: {
success: sshOutcomesMap.get(SSH_SUCCESS_ACTION) ?? 0,
failure: SSH_FAILURE_ACTIONS.reduce(
(total, actionType) => total + (sshOutcomesMap.get(actionType) ?? 0),
0,
),
},
connectionTypes,
actionBreakdown7d,
activityTrend7d,
topConnections,
};
};
@@ -0,0 +1,12 @@
import { Router } from 'express';
import { DashboardController } from './dashboard.controller';
import { isAuthenticated } from '../auth/auth.middleware';
const router = Router();
const dashboardController = new DashboardController();
router.use(isAuthenticated);
router.get('/summary', dashboardController.getSummary);
export default router;
@@ -0,0 +1,8 @@
import { getDashboardSummary } from './dashboard.repository';
import type { DashboardSummary } from './dashboard.types';
export class DashboardService {
async getSummary(): Promise<DashboardSummary> {
return getDashboardSummary();
}
}
@@ -0,0 +1,45 @@
import type { AuditLogActionType } from '../types/audit.types';
export interface DashboardTotals {
connections: number;
activeConnections7d: number;
taggedConnections: number;
auditLogs: number;
}
export interface DashboardSshOutcomes24h {
success: number;
failure: number;
}
export interface DashboardCountByType {
label: string;
count: number;
}
export interface DashboardActivityTrendPoint {
date: string;
count: number;
}
export interface DashboardTopConnection {
connectionId: number;
connectionName: string;
host: string;
count: number;
lastSeenAt: number;
}
export interface DashboardActionBreakdownItem {
actionType: AuditLogActionType | 'OTHER';
count: number;
}
export interface DashboardSummary {
totals: DashboardTotals;
sshOutcomes24h: DashboardSshOutcomes24h;
connectionTypes: DashboardCountByType[];
actionBreakdown7d: DashboardActionBreakdownItem[];
activityTrend7d: DashboardActivityTrendPoint[];
topConnections: DashboardTopConnection[];
}
+2
View File
@@ -56,6 +56,7 @@ import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes';
import { transfersRoutes } from './transfers/transfers.routes';
import pathHistoryRoutes from './path-history/path-history.routes';
import favoritePathsRouter from './favorite-paths/favorite-paths.routes';
import dashboardRoutes from './dashboard/dashboard.routes';
import { initializeWebSocket } from './websocket';
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware';
@@ -263,6 +264,7 @@ const startServer = () => {
app.use('/api/v1/transfers', transfersRoutes());
app.use('/api/v1/path-history', pathHistoryRoutes);
app.use('/api/v1/favorite-paths', favoritePathsRouter);
app.use('/api/v1/dashboard', dashboardRoutes);
// 状态检查接口
app.get('/api/v1/status', (req: Request, res: Response) => {