diff --git a/packages/backend/src/settings/settings.controller.ts b/packages/backend/src/settings/settings.controller.ts index 72cf391..a57b68b 100644 --- a/packages/backend/src/settings/settings.controller.ts +++ b/packages/backend/src/settings/settings.controller.ts @@ -33,7 +33,7 @@ export const settingsController = { const allowedSettingsKeys = [ 'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration', 'showPopupFileEditor', 'shareFileEditorTabs', 'ipWhitelistEnabled', - 'autoCopyOnSelect' // +++ 添加新设置键 +++ + 'autoCopyOnSelect', 'dockerStatusIntervalSeconds', 'dockerDefaultExpand' // +++ 添加 Docker 设置键 +++ ]; const filteredSettings: Record = {}; for (const key in settingsToUpdate) { diff --git a/packages/backend/src/websocket.ts b/packages/backend/src/websocket.ts index 812ea8c..49a1b16 100644 --- a/packages/backend/src/websocket.ts +++ b/packages/backend/src/websocket.ts @@ -11,6 +11,7 @@ import * as SshService from './services/ssh.service'; import { DockerService } from './services/docker.service'; // 导入 DockerService import { AuditLogService } from './services/audit.service'; // 导入 AuditLogService import { AuditLogActionType } from './types/audit.types'; // 导入 AuditLogActionType +import { settingsService } from './services/settings.service'; // +++ 修正导入路径 +++ // 扩展 WebSocket 类型以包含会话 ID interface AuthenticatedWebSocket extends WebSocket { @@ -489,32 +490,52 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re console.log(`WebSocket: 会话 ${newSessionId} 正在启动状态监控...`); statusMonitorService.startStatusPolling(newSessionId); - // 8. Start Docker status polling - console.log(`WebSocket: 会话 ${newSessionId} 正在启动 Docker 状态轮询...`); - const dockerIntervalId = setInterval(async () => { - const currentState = clientStates.get(newSessionId); // Re-fetch state - if (!currentState || currentState.ws.readyState !== WebSocket.OPEN) { - console.log(`[Docker Polling] Session ${newSessionId} no longer valid or WS closed. Stopping poll.`); - clearInterval(dockerIntervalId); - return; - } - try { - // console.log(`[Docker Polling] Fetching status for session ${newSessionId}...`); - const statusPayload = await fetchRemoteDockerStatus(currentState); - if (currentState.ws.readyState === WebSocket.OPEN) { - currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload })); - } - } catch (error: any) { - console.error(`[Docker Polling] Error fetching Docker status for session ${newSessionId}:`, error); - // Optionally send error to client, or just log - // if (currentState.ws.readyState === WebSocket.OPEN) { - // currentState.ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: `Polling failed: ${error.message}` } })); - // } - } - }, DOCKER_STATUS_INTERVAL); - newState.dockerStatusIntervalId = dockerIntervalId; + // 8. Start Docker status polling (using setting) + console.log(`WebSocket: 会话 ${newSessionId} 正在启动 Docker 状态轮询...`); + // --- Get interval from settings --- + let dockerPollIntervalMs = 2000; // Default interval + try { + const intervalSetting = await settingsService.getSetting('dockerStatusIntervalSeconds'); + if (intervalSetting) { + const intervalSeconds = parseInt(intervalSetting, 10); + if (!isNaN(intervalSeconds) && intervalSeconds >= 1) { + dockerPollIntervalMs = intervalSeconds * 1000; + console.log(`[Docker Polling] Using interval from settings: ${intervalSeconds}s (${dockerPollIntervalMs}ms) for session ${newSessionId}`); + } else { + console.warn(`[Docker Polling] Invalid interval setting '${intervalSetting}' found. Using default ${dockerPollIntervalMs}ms for session ${newSessionId}`); + } + } else { + console.log(`[Docker Polling] No interval setting found. Using default ${dockerPollIntervalMs}ms for session ${newSessionId}`); + } + } catch (settingError) { + console.error(`[Docker Polling] Error fetching interval setting for session ${newSessionId}. Using default ${dockerPollIntervalMs}ms:`, settingError); + } + // --- End get interval --- - // 9. Trigger initial Docker status fetch immediately + const dockerIntervalId = setInterval(async () => { + const currentState = clientStates.get(newSessionId); // Re-fetch state + if (!currentState || currentState.ws.readyState !== WebSocket.OPEN) { + console.log(`[Docker Polling] Session ${newSessionId} no longer valid or WS closed. Stopping poll.`); + clearInterval(dockerIntervalId); + return; + } + try { + // console.log(`[Docker Polling] Fetching status for session ${newSessionId}...`); + const statusPayload = await fetchRemoteDockerStatus(currentState); + if (currentState.ws.readyState === WebSocket.OPEN) { + currentState.ws.send(JSON.stringify({ type: 'docker:status:update', payload: statusPayload })); + } + } catch (error: any) { + console.error(`[Docker Polling] Error fetching Docker status for session ${newSessionId}:`, error); + // Optionally send error to client, or just log + // if (currentState.ws.readyState === WebSocket.OPEN) { + // currentState.ws.send(JSON.stringify({ type: 'docker:status:error', payload: { message: `Polling failed: ${error.message}` } })); + // } + } + }, dockerPollIntervalMs); // <-- Use the determined interval + newState.dockerStatusIntervalId = dockerIntervalId; + + // 9. Trigger initial Docker status fetch immediately (async () => { const currentState = clientStates.get(newSessionId); if (currentState && currentState.ws.readyState === WebSocket.OPEN) { diff --git a/packages/frontend/src/components/DockerManager.vue b/packages/frontend/src/components/DockerManager.vue index 208abd4..96f01ec 100644 --- a/packages/frontend/src/components/DockerManager.vue +++ b/packages/frontend/src/components/DockerManager.vue @@ -4,10 +4,13 @@ import { ref, onMounted, onUnmounted, watch, computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useSessionStore } from '../stores/session.store'; // Import session store import { storeToRefs } from 'pinia'; +import { useSettingsStore } from '../stores/settings.store'; // +++ Import settings store +++ const { t } = useI18n(); const sessionStore = useSessionStore(); const { activeSession } = storeToRefs(sessionStore); // Get reactive active session +const settingsStore = useSettingsStore(); // +++ Get settings store instance +++ +const { dockerDefaultExpandBoolean } = storeToRefs(settingsStore); // +++ Get reactive getter +++ // --- Interfaces --- interface PortInfo { @@ -63,6 +66,7 @@ let refreshInterval: ReturnType | null = null; let wsUnsubscribeHooks: (() => void)[] = []; // To store unsubscribe functions // --- State for expansion (multiple allowed) --- const expandedContainerIds = ref>(new Set()); // Use a Set to store multiple IDs +const initialLoadDone = ref(false); // +++ Flag for initial load processing +++ // REMOVED: containerStats, isStatsLoading, statsError maps @@ -109,6 +113,18 @@ const setupWsListeners = () => { }); idsToRemove.forEach(id => expandedContainerIds.value.delete(id)); + // +++ Handle default expand on initial load +++ + if (!initialLoadDone.value && dockerDefaultExpandBoolean.value) { + console.log('[DockerManager] Applying default expand setting.'); + containers.value.forEach(container => { + if (!expandedContainerIds.value.has(container.id)) { + expandedContainerIds.value.add(container.id); + } + }); + initialLoadDone.value = true; // Mark initial load processed + } + // +++ End handle default expand +++ + } else { // Docker available but no containers, or Docker unavailable containers.value = []; @@ -256,6 +272,7 @@ watch([currentSessionId, sshConnectionStatus], ([newSessionId, newSshStatus], [o error.value = null; isDockerAvailable.value = true; // Assume available until fetch attempt expandedContainerIds.value.clear(); // Clear expansion state + initialLoadDone.value = false; // +++ Reset initial load flag +++ if (refreshInterval) { clearInterval(refreshInterval); diff --git a/packages/frontend/src/locales/en.json b/packages/frontend/src/locales/en.json index 90122c1..0b1c935 100644 --- a/packages/frontend/src/locales/en.json +++ b/packages/frontend/src/locales/en.json @@ -612,7 +612,21 @@ "error": { "saveFailed": "Failed to save auto copy setting." } - } + }, + "docker": { + "title": "Docker Manager Settings", + "refreshIntervalLabel": "Status Refresh Interval (seconds):", + "refreshIntervalHint": "How often to fetch Docker container status and stats (minimum 1).", + "defaultExpandLabel": "Expand container details by default", + "saveButton": "Save Docker Settings", + "success": { + "saved": "Docker settings saved successfully." + }, + "error": { + "saveFailed": "Failed to save Docker settings.", + "invalidInterval": "Refresh interval must be a positive integer." + } + } }, "common": { "loading": "Loading...", diff --git a/packages/frontend/src/locales/zh.json b/packages/frontend/src/locales/zh.json index df952c0..5157ae2 100644 --- a/packages/frontend/src/locales/zh.json +++ b/packages/frontend/src/locales/zh.json @@ -612,7 +612,21 @@ "error": { "saveFailed": "保存自动复制设置失败。" } - } + }, + "docker": { + "title": "Docker 管理器设置", + "refreshIntervalLabel": "状态刷新间隔 (秒):", + "refreshIntervalHint": "获取 Docker 容器状态和统计信息的频率(最小为 1)。", + "defaultExpandLabel": "默认展开容器详情", + "saveButton": "保存 Docker 设置", + "success": { + "saved": "Docker 设置已成功保存。" + }, + "error": { + "saveFailed": "保存 Docker 设置失败。", + "invalidInterval": "刷新间隔必须是正整数。" + } + } }, "common": { "loading": "加载中...", diff --git a/packages/frontend/src/stores/settings.store.ts b/packages/frontend/src/stores/settings.store.ts index b7b4d2a..be709c7 100644 --- a/packages/frontend/src/stores/settings.store.ts +++ b/packages/frontend/src/stores/settings.store.ts @@ -14,6 +14,8 @@ interface SettingsState { shareFileEditorTabs?: string; // 'true' or 'false' ipWhitelistEnabled?: string; // 添加 IP 白名单启用状态 'true' or 'false' autoCopyOnSelect?: string; // 'true' or 'false' - 终端选中自动复制 + dockerStatusIntervalSeconds?: string; // NEW: Docker 状态刷新间隔 (秒) + dockerDefaultExpand?: string; // NEW: Docker 默认展开详情 'true' or 'false' // Add other general settings keys here as needed [key: string]: string | undefined; // Allow other string settings } @@ -64,6 +66,14 @@ export const useSettingsStore = defineStore('settings', () => { if (settings.value.autoCopyOnSelect === undefined) { settings.value.autoCopyOnSelect = 'false'; // 默认禁用选中即复制 } + // NEW: Docker setting defaults + if (settings.value.dockerStatusIntervalSeconds === undefined) { + settings.value.dockerStatusIntervalSeconds = '2'; // 默认 2 秒 + } + if (settings.value.dockerDefaultExpand === undefined) { + settings.value.dockerDefaultExpand = 'false'; // 默认不展开 + } + // --- 语言设置 --- const langFromSettings = settings.value.language; @@ -109,7 +119,7 @@ export const useSettingsStore = defineStore('settings', () => { const allowedKeys: Array = [ 'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration', 'showPopupFileEditor', 'shareFileEditorTabs', 'ipWhitelistEnabled', - 'autoCopyOnSelect' // +++ 添加新设置键 +++ + 'autoCopyOnSelect', 'dockerStatusIntervalSeconds', 'dockerDefaultExpand' // +++ 添加 Docker 设置键 +++ ]; if (!allowedKeys.includes(key)) { console.error(`[SettingsStore] 尝试更新不允许的设置键: ${key}`); @@ -141,7 +151,7 @@ export const useSettingsStore = defineStore('settings', () => { const allowedKeys: Array = [ 'language', 'ipWhitelist', 'maxLoginAttempts', 'loginBanDuration', 'showPopupFileEditor', 'shareFileEditorTabs', 'ipWhitelistEnabled', - 'autoCopyOnSelect' // +++ 添加新设置键 +++ + 'autoCopyOnSelect', 'dockerStatusIntervalSeconds', 'dockerDefaultExpand' // +++ 添加 Docker 设置键 +++ ]; const filteredUpdates: Partial = {}; let languageUpdate: 'en' | 'zh' | undefined = undefined; @@ -202,6 +212,11 @@ export const useSettingsStore = defineStore('settings', () => { return settings.value.autoCopyOnSelect === 'true'; }); + // NEW: Getter for Docker default expand setting, returning boolean + const dockerDefaultExpandBoolean = computed(() => { + return settings.value.dockerDefaultExpand === 'true'; + }); + return { settings, // 只包含通用设置 isLoading, @@ -210,7 +225,8 @@ export const useSettingsStore = defineStore('settings', () => { showPopupFileEditorBoolean, shareFileEditorTabsBoolean, ipWhitelistEnabled, // 暴露 IP 白名单启用状态 - autoCopyOnSelectBoolean, // +++ 暴露新 getter +++ + autoCopyOnSelectBoolean, + dockerDefaultExpandBoolean, // +++ 暴露 Docker 默认展开 getter +++ // 移除外观相关的 getters 和 actions loadInitialSettings, updateSetting, diff --git a/packages/frontend/src/views/SettingsView.vue b/packages/frontend/src/views/SettingsView.vue index 4694fe0..2956da2 100644 --- a/packages/frontend/src/views/SettingsView.vue +++ b/packages/frontend/src/views/SettingsView.vue @@ -137,6 +137,25 @@ + +
+

{{ t('settings.docker.title') }}

+
+
+ + + {{ t('settings.docker.refreshIntervalHint') }} +
+
+ + +
+ +

{{ dockerSettingsMessage }}

+
+
+ +

{{ $t('settings.ipWhitelist.title') }}

{{ $t('settings.ipWhitelist.description') }}

@@ -233,7 +252,7 @@ const { t } = useI18n(); // --- Reactive state from store --- // 使用 storeToRefs 获取响应式 getter -const { settings, isLoading: settingsLoading, error: settingsError, showPopupFileEditorBoolean, shareFileEditorTabsBoolean, autoCopyOnSelectBoolean } = storeToRefs(settingsStore); // +++ 添加 autoCopyOnSelectBoolean +++ +const { settings, isLoading: settingsLoading, error: settingsError, showPopupFileEditorBoolean, shareFileEditorTabsBoolean, autoCopyOnSelectBoolean, dockerDefaultExpandBoolean } = storeToRefs(settingsStore); // +++ 添加 dockerDefaultExpandBoolean +++ // --- Local state for forms --- const ipWhitelistInput = ref(''); @@ -265,6 +284,12 @@ const autoCopyEnabled = ref(false); // 本地状态,用于选中即复制 v-mo const autoCopyLoading = ref(false); const autoCopyMessage = ref(''); const autoCopySuccess = ref(false); +const dockerInterval = ref(2); // 本地状态,用于 Docker 刷新间隔 v-model +const dockerExpandDefault = ref(false); // 本地状态,用于 Docker 默认展开 v-model +const dockerSettingsLoading = ref(false); +const dockerSettingsMessage = ref(''); +const dockerSettingsSuccess = ref(false); + // --- Watcher to sync local form state with store state --- watch(settings, (newSettings, oldSettings) => { @@ -279,7 +304,9 @@ watch(settings, (newSettings, oldSettings) => { // 始终将本地布尔状态与 store 的布尔 getter 同步 popupEditorEnabled.value = showPopupFileEditorBoolean.value; shareTabsEnabled.value = shareFileEditorTabsBoolean.value; - autoCopyEnabled.value = autoCopyOnSelectBoolean.value; // +++ 同步选中即复制状态 +++ + autoCopyEnabled.value = autoCopyOnSelectBoolean.value; // 同步选中即复制状态 + dockerInterval.value = parseInt(newSettings.dockerStatusIntervalSeconds || '2', 10); // 同步 Docker 间隔 + dockerExpandDefault.value = dockerDefaultExpandBoolean.value; // 同步 Docker 默认展开状态 }, { deep: true, immediate: true }); // immediate: true to run on initial load @@ -345,6 +372,32 @@ const handleUpdateAutoCopySetting = async () => { } }; +// --- Docker settings method --- +const handleUpdateDockerSettings = async () => { + dockerSettingsLoading.value = true; + dockerSettingsMessage.value = ''; + dockerSettingsSuccess.value = false; + try { + const intervalValue = dockerInterval.value; + if (isNaN(intervalValue) || intervalValue < 1) { + throw new Error(t('settings.docker.error.invalidInterval')); // 需要添加翻译 + } + await settingsStore.updateMultipleSettings({ + dockerStatusIntervalSeconds: String(intervalValue), // 保存为字符串 + dockerDefaultExpand: dockerExpandDefault.value ? 'true' : 'false' // 保存为字符串 'true'/'false' + }); + dockerSettingsMessage.value = t('settings.docker.success.saved'); // 需要添加翻译 + dockerSettingsSuccess.value = true; + } catch (error: any) { + console.error('更新 Docker 设置失败:', error); + dockerSettingsMessage.value = error.message || t('settings.docker.error.saveFailed'); // 需要添加翻译 + dockerSettingsSuccess.value = false; + } finally { + dockerSettingsLoading.value = false; + } +}; + + // --- 外观设置 --- const openStyleCustomizer = () => { appearanceStore.toggleStyleCustomizer(true);