feat(workspace): 支持多行命令输入并新增仪表盘接口
将底部命令输入框改为支持自动增高的多行 textarea, 并把发送快捷键调整为 Ctrl+Shift+Enter,同时更新多语言提示文案 新增 dashboard summary 后端接口与聚合类型定义, 为首页管理驾驶舱改造提供统一数据入口,并同步知识库方案记录
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -68,6 +68,33 @@ const currentSessionCommandInput = computed({
|
||||
}
|
||||
});
|
||||
|
||||
const maxCommandInputRows = 6;
|
||||
|
||||
const syncCommandInputHeight = () => {
|
||||
const textarea = commandInputRef.value;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
const lineHeight = Number.parseFloat(computedStyle.lineHeight) || 20;
|
||||
const verticalPadding = Number.parseFloat(computedStyle.paddingTop) + Number.parseFloat(computedStyle.paddingBottom);
|
||||
const borderWidth = Number.parseFloat(computedStyle.borderTopWidth) + Number.parseFloat(computedStyle.borderBottomWidth);
|
||||
const maxHeight = lineHeight * maxCommandInputRows + verticalPadding + borderWidth;
|
||||
const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
|
||||
|
||||
textarea.style.height = `${nextHeight}px`;
|
||||
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||
};
|
||||
|
||||
const scheduleCommandInputHeightSync = () => {
|
||||
void nextTick(() => {
|
||||
syncCommandInputHeight();
|
||||
});
|
||||
};
|
||||
|
||||
const sendCommand = () => {
|
||||
const command = currentSessionCommandInput.value; // 使用计算属性获取值
|
||||
console.log(`[CommandInputBar] Sending command: ${command || '<Enter>'} `);
|
||||
@@ -125,17 +152,24 @@ watch(currentSessionCommandInput, (newValue) => { // 监听计算属性
|
||||
commandHistoryStore.setSearchTerm(newValue);
|
||||
}
|
||||
// If target is 'none', do nothing
|
||||
scheduleCommandInputHeightSync();
|
||||
});
|
||||
|
||||
// 可以在这里添加一个 ref 用于聚焦搜索框
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null);
|
||||
const commandInputRef = ref<HTMLInputElement | null>(null); // Ref for command input
|
||||
const commandInputRef = ref<HTMLTextAreaElement | null>(null); // Ref for command input
|
||||
|
||||
watch(activeSessionId, () => {
|
||||
scheduleCommandInputHeightSync();
|
||||
});
|
||||
|
||||
// Removed debug computed property
|
||||
|
||||
const handleCommandInputKeydown = (event: KeyboardEvent) => {
|
||||
// --- 移动到外部:优先处理 Enter 键执行选中项 ---
|
||||
if (!event.altKey && event.key === 'Enter') {
|
||||
const isSendShortcut = event.key === 'Enter' && event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey;
|
||||
|
||||
// --- 移动到外部:优先处理发送快捷键执行选中项 ---
|
||||
if (isSendShortcut) {
|
||||
const target = commandInputSyncTarget.value;
|
||||
let selectedCommand: string | undefined;
|
||||
let resetSelection: (() => void) | undefined;
|
||||
@@ -156,7 +190,7 @@ const handleCommandInputKeydown = (event: KeyboardEvent) => {
|
||||
|
||||
if (selectedCommand !== undefined) {
|
||||
event.preventDefault();
|
||||
console.log(`[CommandInputBar] Enter detected with selection. Sending selected command: ${selectedCommand}`);
|
||||
console.log(`[CommandInputBar] Send shortcut detected with selection. Sending selected command: ${selectedCommand}`);
|
||||
emitWorkspaceEvent('terminal:sendCommand', { command: selectedCommand }); // 发送选中命令
|
||||
if (activeSessionId.value) {
|
||||
updateSessionCommandInput(activeSessionId.value, ''); // 清空输入框
|
||||
@@ -164,9 +198,9 @@ const handleCommandInputKeydown = (event: KeyboardEvent) => {
|
||||
resetSelection?.(); // 重置列表选中状态
|
||||
return; // 阻止后续的 Enter 处理
|
||||
}
|
||||
// 如果没有选中项,则继续执行下面的默认 Enter 逻辑
|
||||
// 如果没有选中项,则继续执行下面的默认发送逻辑
|
||||
}
|
||||
// --- 结束:优先处理 Enter 键执行选中项 ---
|
||||
// --- 结束:优先处理发送快捷键执行选中项 ---
|
||||
|
||||
if (event.ctrlKey && event.key === 'f') {
|
||||
event.preventDefault(); // 阻止浏览器默认的查找行为
|
||||
@@ -197,8 +231,8 @@ const handleCommandInputKeydown = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
console.log('[CommandInputBar] Ctrl+C detected with empty input. Sending SIGINT.');
|
||||
emitWorkspaceEvent('terminal:sendCommand', { command: '\x03' }); // Send ETX character (Ctrl+C)
|
||||
} else if (!event.altKey && event.key === 'Enter') {
|
||||
// Handle regular Enter key press - send current input (empty or not)
|
||||
} else if (isSendShortcut) {
|
||||
// Handle Ctrl+Shift+Enter - send current input (empty or not)
|
||||
event.preventDefault(); // Prevent default if needed, e.g., form submission
|
||||
sendCommand(); // Call the existing sendCommand function
|
||||
} else {
|
||||
@@ -271,6 +305,7 @@ let unregisterTerminalSearchFocus: (() => void) | null = null;
|
||||
onMounted(() => {
|
||||
unregisterCommandInputFocus = focusSwitcherStore.registerFocusAction('commandInput', focusCommandInput);
|
||||
unregisterTerminalSearchFocus = focusSwitcherStore.registerFocusAction('terminalSearch', focusSearchInput);
|
||||
scheduleCommandInputHeightSync();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -360,17 +395,18 @@ const handleQuickCommandExecute = (command: string) => {
|
||||
<i class="fas fa-keyboard text-base"></i> <!-- Removed text-primary -->
|
||||
</button>
|
||||
<!-- Command Input (Hide on mobile when searching) -->
|
||||
<input
|
||||
<textarea
|
||||
v-if="!props.isMobile || !isSearching"
|
||||
type="text"
|
||||
v-model="currentSessionCommandInput"
|
||||
rows="1"
|
||||
:placeholder="t('commandInputBar.placeholder')"
|
||||
class="flex-grow min-w-0 px-4 py-1.5 border border-border/50 rounded-lg bg-input text-foreground text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all duration-300 ease-in-out"
|
||||
class="flex-grow min-w-0 px-4 py-1.5 border border-border/50 rounded-lg bg-input text-foreground text-sm leading-5 shadow-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-all duration-300 ease-in-out"
|
||||
:class="{
|
||||
'basis-3/4': !props.isMobile && isSearching, // Desktop searching: 3/4 width
|
||||
'basis-full': !props.isMobile && !isSearching, // Desktop non-searching: full width
|
||||
'w-0': props.isMobile // Mobile non-searching: adjust width to fit
|
||||
}"
|
||||
style="min-height: 38px;"
|
||||
ref="commandInputRef"
|
||||
data-focus-id="commandInput"
|
||||
@keydown="handleCommandInputKeydown"
|
||||
|
||||
@@ -1261,7 +1261,7 @@
|
||||
}
|
||||
},
|
||||
"commandInputBar": {
|
||||
"placeholder": "Enter command and press Enter to send...",
|
||||
"placeholder": "Enter command here; press Enter for a new line, Ctrl+Shift+Enter to send...",
|
||||
"searchPlaceholder": "Search in terminal...",
|
||||
"openSearch": "Open terminal search",
|
||||
"closeSearch": "Close terminal search",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"findNext": "次を検索",
|
||||
"findPrevious": "前を検索",
|
||||
"openSearch": "ターミナル検索を開く",
|
||||
"placeholder": "ここにコマンドを入力して Enter キーを押すと、ターミナルに送信されます...",
|
||||
"placeholder": "ここにコマンドを入力してください。Enter で改行、Ctrl+Shift+Enter でターミナルに送信します...",
|
||||
"searchPlaceholder": "ターミナル内で検索...",
|
||||
"clearTerminal": "ターミナルをクリア"
|
||||
},
|
||||
|
||||
@@ -1265,7 +1265,7 @@
|
||||
}
|
||||
},
|
||||
"commandInputBar": {
|
||||
"placeholder": "在此输入命令后按 Enter 发送到终端...",
|
||||
"placeholder": "在此输入命令;Enter 换行,Ctrl+Shift+Enter 发送到终端...",
|
||||
"searchPlaceholder": "在终端中搜索...",
|
||||
"openSearch": "打开终端搜索",
|
||||
"closeSearch": "关闭终端搜索",
|
||||
|
||||
Reference in New Issue
Block a user