feat(workspace): add cpu core status display and safer quick command actions

Expose `cpuCores` in backend status collection with multi-command fallback
and surface it in the status panel as a localized CPU core badge under the
CPU model.

Adjust terminal group UX by adding a server-level close-all control in the
SSH tab group header.

Reduce accidental quick command execution by switching list interaction to
single-click select + double-click execute, while preserving keyboard Enter
and context-menu execution paths.
This commit is contained in:
yinjianm
2026-04-12 07:22:09 +08:00
parent 840fe292c3
commit 8c130adcc9
26 changed files with 1000 additions and 55 deletions
@@ -36,7 +36,10 @@
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.cpuModelLabel') }}</label>
<span class="cpu-model-value truncate text-left" :title="displayCpuModel">{{ displayCpuModel }}</span>
<div class="cpu-spec-block text-left">
<span class="cpu-model-value truncate" :title="displayCpuModel">{{ displayCpuModel }}</span>
<span class="cpu-core-badge">{{ displayCpuCores }}</span>
</div>
</div>
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
@@ -245,6 +248,7 @@ const displaySwapPercent = computed(() => currentServerStatus.value?.swapPercent
const currentStatusError = computed<string | null>(() => currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null);
const cachedCpuModel = ref<string | null>(null);
const cachedCpuCores = ref<number | null>(null);
const cachedOsName = ref<string | null>(null);
watch(currentServerStatus, newData => {
@@ -252,6 +256,9 @@ watch(currentServerStatus, newData => {
if (newData.cpuModel) {
cachedCpuModel.value = newData.cpuModel;
}
if (typeof newData.cpuCores === 'number' && Number.isFinite(newData.cpuCores)) {
cachedCpuCores.value = newData.cpuCores;
}
if (newData.osName) {
cachedOsName.value = newData.osName;
}
@@ -266,6 +273,14 @@ watch(() => props.activeSessionId, async (newId, oldId) => {
});
const displayCpuModel = computed(() => (currentServerStatus.value?.cpuModel ?? cachedCpuModel.value) || t('statusMonitor.notAvailable'));
const displayCpuCores = computed(() => {
const cpuCores = currentServerStatus.value?.cpuCores ?? cachedCpuCores.value;
if (typeof cpuCores !== 'number' || !Number.isFinite(cpuCores)) {
return t('statusMonitor.notAvailable');
}
return t('statusMonitor.cpuCoresValue', { count: Math.round(cpuCores) });
});
const displayOsName = computed(() => (currentServerStatus.value?.osName ?? cachedOsName.value) || t('statusMonitor.notAvailable'));
const formatBytesPerSecond = (bytes?: number): string => {
@@ -434,6 +449,36 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
border-radius: 14px;
padding: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
container-type: inline-size;
}
.cpu-spec-block {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.cpu-model-value {
display: block;
width: 100%;
}
.cpu-core-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.26);
background: rgba(37, 99, 235, 0.14);
color: #dbeafe;
font-size: 12px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.status-card__header {
@@ -530,6 +575,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 10px;
padding: 8px 10px;
min-width: 0;
}
.memory-stat__label,
@@ -537,6 +583,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-secondary-color, #9ca3af);
}
@@ -550,6 +597,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
font-weight: 700;
color: #f8fafc;
line-height: 1.15;
overflow-wrap: anywhere;
}
.memory-stat__dot {
@@ -654,6 +702,11 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
align-items: center;
}
.disk-table__header > span,
.disk-table__row > span {
min-width: 0;
}
.disk-table__header {
color: var(--text-secondary-color, #9ca3af);
font-size: 12px;
@@ -688,6 +741,67 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
border: 1px solid rgba(34, 197, 94, 0.18);
}
@container (max-width: 320px) {
.status-card__header {
flex-wrap: wrap;
}
.memory-card__content,
.disk-card__body {
grid-template-columns: 1fr;
}
.memory-ring,
.disk-usage-tube {
justify-self: center;
}
.memory-stats-grid,
.disk-rate-grid {
grid-template-columns: 1fr;
}
.memory-stat__value,
.disk-rate-card__value {
font-size: 18px;
}
.disk-meta-row {
flex-direction: column;
align-items: flex-start;
}
.disk-table__header,
.disk-table__row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container (max-width: 250px) {
.status-card {
padding: 12px;
}
.status-card__badge {
white-space: normal;
}
.memory-stat__value,
.disk-rate-card__value {
font-size: 16px;
}
.disk-table__header,
.disk-table__row {
grid-template-columns: 1fr;
}
.disk-mount-pill,
.disk-percent-pill {
width: 100%;
}
}
@media (max-width: 640px) {
.memory-card__content,
.disk-card__body {
+16 -8
View File
@@ -42,7 +42,7 @@ let lastResizeObserverWidth = 0;
let lastResizeObserverHeight = 0;
const RESIZE_THRESHOLD = 0.5; // px
const BOTTOM_STICK_THRESHOLD = 2;
let lastKnownViewportLine = 0;
let lastKnownDistanceFromBottom = 0;
let lastKnownShouldStickToBottom = true;
@@ -95,7 +95,7 @@ const debounce = (func: Function, delay: number) => {
};
type TerminalViewportSnapshot = {
viewportLine: number;
distanceFromBottom: number;
shouldStickToBottom: boolean;
};
@@ -103,30 +103,38 @@ const getViewportSnapshot = (term: Terminal): TerminalViewportSnapshot => {
const buffer = term.buffer.active;
const maxScrollLine = Math.max(0, buffer.baseY);
const viewportLine = Math.max(0, Math.min(buffer.viewportY, maxScrollLine));
// Keep a relative offset from the live bottom so hidden terminals can recover
// the same reading position even if logs keep appending while inactive.
const distanceFromBottom = Math.max(0, maxScrollLine - viewportLine);
return {
viewportLine,
shouldStickToBottom: maxScrollLine - viewportLine <= BOTTOM_STICK_THRESHOLD,
distanceFromBottom,
shouldStickToBottom: distanceFromBottom <= BOTTOM_STICK_THRESHOLD,
};
};
const syncViewportTracking = (term: Terminal): TerminalViewportSnapshot => {
const snapshot = getViewportSnapshot(term);
lastKnownViewportLine = snapshot.viewportLine;
lastKnownDistanceFromBottom = snapshot.distanceFromBottom;
lastKnownShouldStickToBottom = snapshot.shouldStickToBottom;
return snapshot;
};
const restoreViewportSnapshot = (term: Terminal, snapshot?: TerminalViewportSnapshot) => {
const effectiveSnapshot = snapshot ?? {
viewportLine: lastKnownViewportLine,
distanceFromBottom: lastKnownDistanceFromBottom,
shouldStickToBottom: lastKnownShouldStickToBottom,
};
const maxScrollLine = Math.max(0, term.buffer.active.baseY);
if (effectiveSnapshot.shouldStickToBottom) {
term.scrollToBottom();
} else {
const targetLine = Math.min(effectiveSnapshot.viewportLine, Math.max(0, term.buffer.active.baseY));
const targetDistanceFromBottom = Math.min(
Math.max(0, effectiveSnapshot.distanceFromBottom),
maxScrollLine,
);
const targetLine = Math.max(0, maxScrollLine - targetDistanceFromBottom);
term.scrollToLine(targetLine);
}
@@ -418,7 +426,7 @@ onMounted(() => {
// --- Become Active ---
console.log(`[Terminal ${props.sessionId}] Becoming active. Observing element and fitting.`);
const activationViewportSnapshot = {
viewportLine: lastKnownViewportLine,
distanceFromBottom: lastKnownDistanceFromBottom,
shouldStickToBottom: lastKnownShouldStickToBottom,
};
// Start observing
@@ -58,6 +58,16 @@ const closeSession = (event: MouseEvent, sessionId: string) => {
emitWorkspaceEvent('session:close', { sessionId });
};
const closeConnectionGroup = (event: MouseEvent, connectionId: string) => {
event.preventDefault();
event.stopPropagation();
const sessionIds = getConnectionSessions(connectionId).map((session) => session.sessionId);
sessionIds.forEach((sessionId) => {
emitWorkspaceEvent('session:close', { sessionId });
});
};
// --- ---
const sessionStore = useSessionStore(); // Session store
const showConnectionListPopup = ref(false); //
@@ -534,34 +544,49 @@ onBeforeUnmount(() => {
:class="['flex h-full flex-shrink-0 items-stretch py-1', isGroupStart(index) ? 'pl-1' : 'pl-0']"
@dragstart="handleDragStart"
>
<button
<div
v-if="isSshConnection(session.connectionId)"
type="button"
:class="[
'group flex h-full items-center gap-2 rounded-md border px-3 text-left transition-all duration-150',
'group flex h-full items-center rounded-md border px-3 transition-all duration-150',
session.connectionId === activeConnectionId
? 'border-primary/60 bg-primary/10 text-foreground shadow-[0_0_0_1px_rgba(34,197,94,0.18)]'
: 'border-border/70 bg-header/80 text-text-secondary shadow-[0_0_0_1px_rgba(0,0,0,0.08)] hover:bg-border hover:text-foreground',
]"
:title="getTopLevelItemTitle(session)"
@click="activateTopLevelItem(session)"
@contextmenu.prevent="showTopLevelContextMenu($event, session)"
>
<i class="fas fa-server text-[11px] text-primary/80"></i>
<span class="max-w-[180px] truncate text-xs font-semibold tracking-wide">
{{ session.connectionName }}
</span>
<span
:class="[
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
session.connectionId === activeConnectionId
? 'bg-primary/15 text-foreground/90'
: 'bg-black/20 text-text-secondary group-hover:text-foreground',
]"
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-2 text-left"
@click="activateTopLevelItem(session)"
>
{{ getConnectionSessionCount(session.connectionId) }}
</span>
</button>
<i class="fas fa-server text-[11px] text-primary/80"></i>
<span class="max-w-[180px] truncate text-xs font-semibold tracking-wide">
{{ session.connectionName }}
</span>
<span
:class="[
'rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
session.connectionId === activeConnectionId
? 'bg-primary/15 text-foreground/90'
: 'bg-black/20 text-text-secondary group-hover:text-foreground',
]"
>
{{ getConnectionSessionCount(session.connectionId) }}
</span>
</button>
<button
type="button"
class="ml-2 rounded-full p-0.5 text-text-secondary opacity-0 transition-opacity duration-150 hover:bg-header hover:text-foreground group-hover:opacity-100"
:class="{ 'text-foreground': session.connectionId === activeConnectionId }"
:title="t('terminalTabBar.closeConnectionGroupTooltip', { name: session.connectionName, count: getConnectionSessionCount(session.connectionId) })"
@click="closeConnectionGroup($event, session.connectionId)"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div
v-else
:class="[
@@ -278,7 +278,18 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
// Helper function to parse a single script line using minimist
const stripWrappedQuotes = (value: string): string => {
if (value.length >= 2) {
const firstChar = value[0];
const lastChar = value[value.length - 1];
if ((firstChar === '"' || firstChar === '\'') && firstChar === lastChar) {
return value.slice(1, -1);
}
}
return value;
};
const parseScriptLine = (line: string): { type: 'SSH' | 'RDP' | 'VNC', userHostPort: string, name: string, password: string | null, keyName: string | null, proxyName: string | null, tags: string[], note: string | null, error?: string } => {
line = line.trim();
if (!line) {
@@ -309,8 +320,8 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
let note: string | null = null;
// 4. Parse optionsString
// Regex to split by space, respecting quotes
const args = optionsString.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
// Regex to split by space, respecting both single and double quotes
const args = optionsString.match(/"[^"]*"|'[^']*'|[^\s]+/g) || [];
let i = 0;
while (i < args.length) {
const arg = args[i];
@@ -322,7 +333,7 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
// Handle -tags, which can be followed by zero or more tags
tags = [];
while (i < args.length && !args[i].startsWith('-')) {
tags.push(args[i].replace(/^"|"$/g, '')); // Remove surrounding quotes
tags.push(stripWrappedQuotes(args[i]));
i++;
}
// No need to i++ here, the next loop iteration or outer loop handles it
@@ -333,14 +344,14 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
noteParts.push(args[i]);
i++;
}
note = noteParts.join(' ').replace(/^"|"$/g, ''); // Join parts and remove quotes
note = stripWrappedQuotes(noteParts.join(' '));
break; // Exit the outer loop as note consumes the rest
} else if (i >= args.length) {
// All other options require a value
return { type, userHostPort: userHostPortPart, name, password, keyName, proxyName, tags, note, error: t('connections.form.scriptErrorMissingValueForKey', { key: arg }) };
} else {
// Handle options that require a single value
const value = args[i].replace(/^"|"$/g, ''); // Remove surrounding quotes
const value = stripWrappedQuotes(args[i]);
switch (key) {
case 'type':
const typeValue = value.toUpperCase();
+2
View File
@@ -626,6 +626,7 @@
"errorPrefix": "Error:",
"loading": "Waiting for data...",
"cpuModelLabel": "CPU Model:",
"cpuCoresValue": "{count} cores",
"osLabel": "OS:",
"cpuLabel": "CPU:",
"memoryLabel": "Memory:",
@@ -1627,6 +1628,7 @@
"selectServerTitle": "Select server to connect",
"showTransferProgressTooltip": "Show/Hide Transfer Progress",
"newTerminalTooltip": "Open another terminal for the current server",
"closeConnectionGroupTooltip": "Close all terminals for {name} ({count})",
"openConnectionPickerTooltip": "Choose another server",
"terminalBadge": "Terminal {index}",
"serverEntryTitle": "{name} · {count} terminals",
+2
View File
@@ -1365,6 +1365,7 @@
"bytesPerSecond": "B/秒",
"cpuLabel": "CPU:",
"cpuModelLabel": "CPU モデル:",
"cpuCoresValue": "{count} コア",
"diskLabel": "ディスク:",
"errorPrefix": "エラー:",
"gigaBytes": "GB",
@@ -1587,6 +1588,7 @@
"selectServerTitle": "接続するサーバーを選択",
"showTransferProgressTooltip": "転送進捗の表示/非表示",
"newTerminalTooltip": "現在のサーバーに新しいターミナルを追加",
"closeConnectionGroupTooltip": "{name} の端末をすべて閉じる ({count} 件)",
"openConnectionPickerTooltip": "別のサーバーを選択",
"terminalBadge": "端末 {index}"
},
+2
View File
@@ -626,6 +626,7 @@
"errorPrefix": "错误:",
"loading": "等待数据...",
"cpuModelLabel": "CPU 型号:",
"cpuCoresValue": "{count} 核",
"osLabel": "系统:",
"cpuLabel": "CPU:",
"memoryLabel": "内存:",
@@ -1631,6 +1632,7 @@
"selectServerTitle": "选择要连接的服务器",
"showTransferProgressTooltip": "显示/隐藏传输进度",
"newTerminalTooltip": "为当前服务器新增终端",
"closeConnectionGroupTooltip": "关闭 {name} 的全部终端({count} 个)",
"openConnectionPickerTooltip": "选择其他服务器",
"terminalBadge": "终端 {index}",
"serverEntryTitle": "{name} · {count} 个终端",
@@ -1,6 +1,7 @@
// 类型定义:用于服务器状态监控数据 (从 useStatusMonitor 迁移)
export interface ServerStatus {
cpuPercent?: number;
cpuCores?: number;
memPercent?: number;
memUsed?: number; // MB
memTotal?: number; // MB
@@ -111,10 +111,12 @@
v-for="(cmd) in groupData.commands"
:key="cmd.id"
:data-command-id="cmd.id"
:title="cmd.command"
class="group flex justify-between items-center mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150"
:style="{ padding: isCompactMode ? `calc(0.1rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.625rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }"
:class="{ 'bg-primary/20 font-medium': isCommandSelected(cmd.id) }"
@click="executeCommand(cmd)"
@click="selectCommand(cmd.id)"
@dblclick="executeCommand(cmd)"
@contextmenu.prevent="showQuickCommandContextMenu($event, cmd)"
>
<!-- Command Info -->
@@ -157,10 +159,12 @@
v-for="(cmd) in flatFilteredCommands"
:key="cmd.id"
:data-command-id="cmd.id"
:title="cmd.command"
class="group flex justify-between items-center mb-1 cursor-pointer rounded-md hover:bg-primary/10 transition-colors duration-150"
:style="{ padding: isCompactMode ? `calc(0.1rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` : `calc(0.625rem * var(--qc-row-size-multiplier)) calc(0.75rem * var(--qc-row-size-multiplier))` }"
:class="{ 'bg-primary/20 font-medium': isCommandSelected(cmd.id) }"
@click="executeCommand(cmd)"
@click="selectCommand(cmd.id)"
@dblclick="executeCommand(cmd)"
@contextmenu.prevent="showQuickCommandContextMenu($event, cmd)"
>
<!-- Command Info -->
@@ -392,6 +396,10 @@ const isCommandSelected = (commandId: number): boolean => {
return flatVisibleCommands.value[storeSelectedIndex.value].id === commandId;
};
const selectCommand = (commandId: number) => {
storeSelectedIndex.value = flatVisibleCommands.value.findIndex((cmd) => cmd.id === commandId);
};
// --- ---