diff --git a/frontend/index.html b/frontend/index.html
index 07b51c5..35e9059 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -43,10 +43,6 @@
-
-
- 离线
-
@@ -248,7 +244,7 @@
data-i18n-aria-label-zh="实时连接状态"
data-i18n-aria-label-en="Live connection status"
>
-
+
无活动会话
选择目标并连接
@@ -297,9 +293,10 @@
aria-controls="file-manager-panel"
aria-expanded="false"
tabindex="0"
- data-i18n-zh="文件管理"
- data-i18n-en="Files"
- >文件管理
+ >
+
+
文件管理
+
+ >
+
+
会话日志
+
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index e044422..281aa65 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -272,8 +272,6 @@ const ui = {
formError: element('form-error'),
connect: element('connect-button'),
shareLink: element('share-link'),
- globalStatus: element('global-status'),
- globalStatusLabel: element('global-status-label'),
languageToggle: element('language-toggle'),
themeToggle: element('theme-toggle'),
sessionTitle: element('session-title'),
@@ -1049,8 +1047,7 @@ function toast(message: string, kind: 'info' | 'error' = 'info'): void {
function setState(state: ConnectionState, label?: string): void {
connectionState = state;
- ui.globalStatus.dataset.state = state;
- ui.globalStatusLabel.textContent = label ?? ({
+ const stateLabel = label ?? ({
idle: bilingual('离线', 'Offline'),
connecting: bilingual('连接中', 'Connecting'),
connected: bilingual('在线', 'Online'),
@@ -1058,6 +1055,8 @@ function setState(state: ConnectionState, label?: string): void {
error: bilingual('错误', 'Error'),
} satisfies Record)[state];
ui.liveOrb.className = `live-orb ${state}`;
+ ui.liveOrb.setAttribute('aria-label', stateLabel);
+ ui.liveOrb.title = stateLabel;
const control = resolveConnectionControl(state, historyPasswordLoading);
const controlLabel = control.action === 'cancel'
? bilingual('取消连接', 'Cancel connection')
@@ -1489,7 +1488,7 @@ async function connect(): Promise {
ui.sessionSubtitle.textContent = localize(currentSessionSubtitle);
ui.metricRtt.textContent = '--';
ui.metricHostKey.textContent = '--';
- event(bilingual(`正在连接 ${username}@${host}:${port}`, `Starting ${username}@${host}:${port}`), 'connect');
+ event(bilingual(`正在连接 ${currentTargetLabel}`, `Starting ${currentTargetLabel}`), 'connect');
try {
const password = ui.password.value;
diff --git a/frontend/src/style.css b/frontend/src/style.css
index df8b42f..5cf7f9d 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -147,8 +147,7 @@ button:disabled {
.event-bar,
.file-picker,
.profile-main,
-.profile-meta,
-.status-pill {
+.profile-meta {
display: flex;
align-items: center;
}
@@ -289,69 +288,72 @@ button:disabled {
stroke: none;
}
-.status-pill {
- gap: 7px;
- min-width: 92px;
- justify-content: center;
- padding: 7px 11px;
- border: 1px solid var(--border);
- border-radius: 999px;
- background: var(--surface);
- color: var(--muted);
- font-family: var(--mono);
- font-size: 10px;
- font-weight: 700;
- text-transform: uppercase;
-}
-
-.status-dot,
.live-orb {
+ position: relative;
display: block;
- width: 7px;
- height: 7px;
+ width: 9px;
+ height: 9px;
flex: 0 0 auto;
+ margin: 0 2px;
border-radius: 50%;
background: var(--muted-2);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--muted-2) 14%, transparent);
+ box-shadow:
+ 0 0 0 3px color-mix(in srgb, var(--muted-2) 16%, transparent),
+ inset 0 1px 1px rgba(255, 255, 255, 0.25);
+ transition: background 180ms ease, box-shadow 180ms ease, transform 180ms ease;
+}
+
+.live-orb::after {
+ position: absolute;
+ inset: -5px;
+ border: 1px solid transparent;
+ border-radius: inherit;
+ content: '';
+ pointer-events: none;
}
-.status-pill[data-state='connecting'] .status-dot,
.live-orb.connecting {
background: var(--warning);
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--warning) 14%, transparent);
- animation: pulse 1.25s ease-in-out infinite;
+ box-shadow:
+ 0 0 0 3px color-mix(in srgb, var(--warning) 18%, transparent),
+ 0 0 12px color-mix(in srgb, var(--warning) 42%, transparent);
}
-.status-pill[data-state='disconnecting'] {
- color: var(--danger);
+.live-orb.connecting::after {
+ border-color: color-mix(in srgb, var(--warning) 50%, transparent);
+ animation: live-orb-ring 1.35s ease-out infinite;
}
-.status-pill[data-state='disconnecting'] .status-dot,
.live-orb.disconnecting {
background: var(--danger);
- box-shadow: 0 0 0 3px var(--danger-dim);
- animation: pulse 1.25s ease-in-out infinite;
+ box-shadow:
+ 0 0 0 3px var(--danger-dim),
+ 0 0 10px color-mix(in srgb, var(--danger) 35%, transparent);
+ animation: live-orb-pulse 1.1s ease-in-out infinite;
}
-.status-pill[data-state='connected'] {
- border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
- color: var(--accent);
-}
-
-.status-pill[data-state='connected'] .status-dot,
.live-orb.connected {
background: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-dim), 0 0 12px color-mix(in srgb, var(--accent) 50%, transparent);
+ box-shadow:
+ 0 0 0 3px var(--accent-dim),
+ 0 0 14px color-mix(in srgb, var(--accent) 55%, transparent),
+ inset 0 1px 1px rgba(255, 255, 255, 0.35);
}
-.status-pill[data-state='error'] {
- color: var(--danger);
-}
-
-.status-pill[data-state='error'] .status-dot,
.live-orb.error {
background: var(--danger);
- box-shadow: 0 0 0 3px var(--danger-dim);
+ box-shadow:
+ 0 0 0 3px var(--danger-dim),
+ 0 0 12px color-mix(in srgb, var(--danger) 45%, transparent);
+}
+
+@keyframes live-orb-ring {
+ 0% { opacity: 0.9; transform: scale(0.55); }
+ 75%, 100% { opacity: 0; transform: scale(1.35); }
+}
+
+@keyframes live-orb-pulse {
+ 50% { transform: scale(0.72); opacity: 0.7; }
}
.app-shell {
@@ -1141,7 +1143,11 @@ button:disabled {
.dock-tab {
position: relative;
+ display: inline-flex;
min-width: 92px;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
padding: 0 13px;
border: 0;
border-left: 1px solid var(--border);
@@ -1151,6 +1157,17 @@ button:disabled {
font-size: inherit;
}
+.dock-tab-icon {
+ width: 14px;
+ height: 14px;
+ flex: 0 0 auto;
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 1.7;
+}
+
.dock-tab:hover {
background: var(--surface-3);
color: var(--text);
@@ -1665,10 +1682,6 @@ button:disabled {
display: none;
}
-@keyframes pulse {
- 50% { opacity: 0.45; }
-}
-
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
}
@@ -1810,15 +1823,6 @@ button:disabled {
display: none;
}
- .status-pill {
- min-width: 0;
- padding: 7px 9px;
- }
-
- #global-status-label {
- display: none;
- }
-
.field-grid.two {
grid-template-columns: 1fr;
}
diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts
index 45bf00f..6271264 100644
--- a/src/backend/durable-object.ts
+++ b/src/backend/durable-object.ts
@@ -130,7 +130,11 @@ export class SSHSessionDO implements DurableObject {
if (config.port === 25) throw new Error('Cloudflare Workers cannot connect to outbound TCP port 25');
const verifiedAddresses = await assertPublicTarget(config.host);
this.assertConnectionActive(ws, pending);
- ws.send(JSON.stringify({ type: 'status', event: 'tcp_connecting', message: `Connecting to ${config.host}:${config.port}` }));
+ ws.send(JSON.stringify({
+ type: 'status',
+ event: 'tcp_connecting',
+ message: `Connecting to ${toSocketHostname(config.host)}:${config.port}`,
+ }));
// Connect to the exact address that passed SSRF validation. Keeping the
// original hostname only for host-key pinning prevents DNS rebinding.