From 9167b102e846335dd6bab683f77156c899bf8890 Mon Sep 17 00:00:00 2001 From: holole Date: Sat, 21 Feb 2026 00:01:45 +0900 Subject: [PATCH 01/12] refactor(dashboard): replace SSH copy/terminal flow with access guide modal --- frontend/src/i18n/locales/en/common.ts | 5 + frontend/src/i18n/locales/ko/common.ts | 5 + frontend/src/pages/Dashboard.tsx | 122 ++++++++++--------------- 3 files changed, 60 insertions(+), 72 deletions(-) diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 1143f89..38a48bc 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -91,8 +91,13 @@ const enCommon = { noCustomPorts: 'No Custom Ports', copySshCommand: 'Copy SSH command (port: {{port}})', openInTerminal: 'Open in Terminal (port: {{port}})', + openSshGuide: 'Open SSH guide (port: {{port}})', openInTerminalWorkerUnsupported: 'Direct SSH from dashboard is available only for host environments.', environmentMustBeRunning: 'Environment must be running (port: {{port}})', + sshAccessGuideTitle: 'SSH Access Guide', + sshAccessGuideFor: 'Connection guide for {{name}}', + sshAccessGuideHostInfo: 'Target host: {{host}} · SSH port: {{port}}', + sshCommand: 'SSH Command', openJupyterLab: 'Open Jupyter Lab', openCodeServer: 'Open code-server', hostServer: 'Host', diff --git a/frontend/src/i18n/locales/ko/common.ts b/frontend/src/i18n/locales/ko/common.ts index 655926d..fd95707 100644 --- a/frontend/src/i18n/locales/ko/common.ts +++ b/frontend/src/i18n/locales/ko/common.ts @@ -89,8 +89,13 @@ const koCommon = { noCustomPorts: '커스텀 포트 없음', copySshCommand: 'SSH 명령 복사 (포트: {{port}})', openInTerminal: '터미널에서 열기 (포트: {{port}})', + openSshGuide: 'SSH 안내 열기 (포트: {{port}})', openInTerminalWorkerUnsupported: '대시보드 직접 SSH는 호스트 환경에서만 지원됩니다.', environmentMustBeRunning: '환경이 실행 중이어야 합니다 (포트: {{port}})', + sshAccessGuideTitle: 'SSH 접속 안내', + sshAccessGuideFor: '{{name}} 접속 안내', + sshAccessGuideHostInfo: '대상 호스트: {{host}} · SSH 포트: {{port}}', + sshCommand: 'SSH 명령', openJupyterLab: 'Jupyter Lab 열기', openCodeServer: 'code-server 열기', hostServer: '호스트', diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 876c811..8ca6535 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,7 +4,6 @@ import { isValidElement, useCallback, useEffect, useMemo, useRef, useState, type import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; import Modal from '../components/Modal'; import OverlayPortal from '../components/OverlayPortal'; import { useApp } from '../context/AppContext'; @@ -43,14 +42,12 @@ interface Environment { } const ENVS_CACHE_KEY = 'lyra.dashboard.environments'; -const TERMINAL_ACTION_QUEUE_KEY = 'lyra.terminal.pending_action'; const NOTICE_OPEN_KEY = 'lyra.dashboard.notice_open'; const MIN_REFRESH_SPIN_MS = 900; export default function Dashboard() { const { showToast } = useToast(); const { t } = useTranslation(); - const navigate = useNavigate(); const { announcementMarkdown } = useApp(); const hasAnnouncement = announcementMarkdown.trim().length > 0; const [isNoticeOpen, setIsNoticeOpen] = useState(() => { @@ -88,6 +85,7 @@ export default function Dashboard() { const [errorLogEnv, setErrorLogEnv] = useState(null); const [errorLog, setErrorLog] = useState(""); const [logLoading, setLogLoading] = useState(false); + const [sshGuideEnv, setSshGuideEnv] = useState(null); const [workerErrorInfo, setWorkerErrorInfo] = useState<{ name: string; message: string } | null>(null); const [actionLoading, setActionLoading] = useState>({}); const [isRefreshSpinning, setIsRefreshSpinning] = useState(false); @@ -438,71 +436,29 @@ export default function Dashboard() { }); }; - const openEnvInTerminal = (env: Environment) => { - const resolveSshHost = () => { - if (!env.worker_server_name) { - return '127.0.0.1'; - } - const baseUrl = env.worker_server_base_url || ''; - if (baseUrl) { - try { - return new URL(baseUrl).hostname; - } catch { - // Fall through to worker name. - } + const resolveSshHost = (env: Environment) => { + if (!env.worker_server_name) { + return '127.0.0.1'; + } + const baseUrl = env.worker_server_base_url || ''; + if (baseUrl) { + try { + return new URL(baseUrl).hostname; + } catch { + // Fall through to worker name. } - return env.worker_server_name; - }; + } + return env.worker_server_name; + }; - const host = resolveSshHost(); + const buildSshCommand = (env: Environment) => { + const host = resolveSshHost(env); const sshUser = env.container_user || 'root'; - const sshCommand = `ssh -p ${env.ssh_port} ${sshUser}@${host}`; - - if (env.worker_server_name) { - const copyWithFallback = async (text: string) => { - if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; - } - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', 'true'); - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - const copied = document.execCommand('copy'); - document.body.removeChild(textarea); - if (!copied) { - throw new Error('copy_failed'); - } - }; - - copyWithFallback(sshCommand) - .then(() => { - showToast(t('feedback.dashboard.sshCopied'), 'success'); - }) - .catch(() => { - showToast(t('feedback.dashboard.copyFailedRunManually', { command: sshCommand }), 'error'); - }); - return; - } + return `ssh -p ${env.ssh_port} ${sshUser}@${host}`; + }; - try { - window.localStorage.setItem( - TERMINAL_ACTION_QUEUE_KEY, - JSON.stringify({ - type: 'open_tab_and_run', - command: sshCommand, - environmentName: env.name, - requestedAt: Date.now(), - }), - ); - } catch { - // Ignore storage failures and still move to terminal page. - } - navigate('/terminal'); + const openSshAccessGuide = (env: Environment) => { + setSshGuideEnv(env); }; const openJupyter = async (env: Environment) => { @@ -606,26 +562,20 @@ export default function Dashboard() { const jupyterEnabled = env.enable_jupyter !== false; const codeEnabled = env.enable_code_server !== false; - const isWorkerEnv = Boolean(env.worker_server_name); - const accessItems: Array<{ key: string; node: ReactNode }> = [ { key: 'ssh', node: (
- {isWorkerEnv && isRunning - ? t('dashboard.copySshCommand', { port: env.ssh_port }) - : !isWorkerEnv && isRunning - ? t('dashboard.openInTerminal', { port: env.ssh_port }) - : t('dashboard.environmentMustBeRunning', { port: env.ssh_port })} + {isRunning ? t('dashboard.openSshGuide', { port: env.ssh_port }) : t('dashboard.environmentMustBeRunning', { port: env.ssh_port })}
), @@ -723,6 +673,34 @@ export default function Dashboard() { message={t('dashboard.forceDeleteEnvironmentMessage')} isDestructive={true} /> + {sshGuideEnv && ( + +
+
+

{t('dashboard.sshAccessGuideTitle')}

+ +
+
+

{t('dashboard.sshAccessGuideFor', { name: sshGuideEnv.name })}

+

{t('dashboard.sshAccessGuideHostInfo', { host: resolveSshHost(sshGuideEnv), port: sshGuideEnv.ssh_port })}

+
+
{t('dashboard.sshCommand')}
+
{buildSshCommand(sshGuideEnv)}
+
+
+
+ +
+
+
+ )} {/* Volume Details Modal */} {selectedVolEnv && ( From 81cdfd8aa6d2ddd8b342899ba47c26e831d25be1 Mon Sep 17 00:00:00 2001 From: holole Date: Sat, 21 Feb 2026 00:08:52 +0900 Subject: [PATCH 02/12] test(dashboard): cover ssh guide template generation and fallbacks --- frontend/package-lock.json | 442 ++++++++++++++++++++++++- frontend/package.json | 4 +- frontend/src/i18n/locales/en/common.ts | 3 + frontend/src/i18n/locales/ko/common.ts | 3 + frontend/src/pages/Dashboard.tsx | 40 +-- frontend/src/utils/sshGuide.test.ts | 58 ++++ frontend/src/utils/sshGuide.ts | 74 +++++ 7 files changed, 598 insertions(+), 26 deletions(-) create mode 100644 frontend/src/utils/sshGuide.test.ts create mode 100644 frontend/src/utils/sshGuide.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e29082..e0074ce 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,7 +40,8 @@ "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^3.2.4" } }, "node_modules/@alloc/quick-lru": { @@ -1745,6 +1746,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1754,6 +1766,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2141,6 +2160,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2205,6 +2339,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2332,6 +2476,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2386,6 +2540,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2443,6 +2614,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2572,6 +2753,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2682,6 +2873,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2959,6 +3157,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2969,6 +3177,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3939,6 +4157,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5051,6 +5276,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5436,6 +5678,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5456,12 +5705,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5489,6 +5752,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -5551,6 +5834,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5568,6 +5865,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5907,6 +6234,102 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -5932,6 +6355,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4584ac5..a5ec839 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "test": "vitest run", "lint": "eslint .", "preview": "vite preview", "i18n:scan": "node scripts/i18n-scan.mjs", @@ -44,6 +45,7 @@ "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^3.2.4" } } diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 38a48bc..9852b8e 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -97,6 +97,9 @@ const enCommon = { sshAccessGuideTitle: 'SSH Access Guide', sshAccessGuideFor: 'Connection guide for {{name}}', sshAccessGuideHostInfo: 'Target host: {{host}} · SSH port: {{port}}', + sshGuideOneShot: 'One-shot command', + sshGuideConfigExample: 'SSH config example', + sshGuideAliases: 'Aliases: jump={{jumpAlias}}, env={{envAlias}}', sshCommand: 'SSH Command', openJupyterLab: 'Open Jupyter Lab', openCodeServer: 'Open code-server', diff --git a/frontend/src/i18n/locales/ko/common.ts b/frontend/src/i18n/locales/ko/common.ts index fd95707..202271d 100644 --- a/frontend/src/i18n/locales/ko/common.ts +++ b/frontend/src/i18n/locales/ko/common.ts @@ -95,6 +95,9 @@ const koCommon = { sshAccessGuideTitle: 'SSH 접속 안내', sshAccessGuideFor: '{{name}} 접속 안내', sshAccessGuideHostInfo: '대상 호스트: {{host}} · SSH 포트: {{port}}', + sshGuideOneShot: '즉시 실행 명령', + sshGuideConfigExample: 'SSH config 예시', + sshGuideAliases: '별칭: 점프={{jumpAlias}}, 환경={{envAlias}}', sshCommand: 'SSH 명령', openJupyterLab: 'Jupyter Lab 열기', openCodeServer: 'code-server 열기', diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 8ca6535..b1300cc 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -8,6 +8,7 @@ import Modal from '../components/Modal'; import OverlayPortal from '../components/OverlayPortal'; import { useApp } from '../context/AppContext'; import { useToast } from '../context/ToastContext'; +import { buildSshGuide } from '../utils/sshGuide'; interface MountConfig { host_path: string; @@ -436,27 +437,6 @@ export default function Dashboard() { }); }; - const resolveSshHost = (env: Environment) => { - if (!env.worker_server_name) { - return '127.0.0.1'; - } - const baseUrl = env.worker_server_base_url || ''; - if (baseUrl) { - try { - return new URL(baseUrl).hostname; - } catch { - // Fall through to worker name. - } - } - return env.worker_server_name; - }; - - const buildSshCommand = (env: Environment) => { - const host = resolveSshHost(env); - const sshUser = env.container_user || 'root'; - return `ssh -p ${env.ssh_port} ${sshUser}@${host}`; - }; - const openSshAccessGuide = (env: Environment) => { setSshGuideEnv(env); }; @@ -675,6 +655,9 @@ export default function Dashboard() { /> {sshGuideEnv && ( + {(() => { + const guide = buildSshGuide(sshGuideEnv); + return (

{t('dashboard.sshAccessGuideTitle')}

@@ -684,11 +667,18 @@ export default function Dashboard() {

{t('dashboard.sshAccessGuideFor', { name: sshGuideEnv.name })}

-

{t('dashboard.sshAccessGuideHostInfo', { host: resolveSshHost(sshGuideEnv), port: sshGuideEnv.ssh_port })}

+

+ {t('dashboard.sshAccessGuideHostInfo', { host: guide.jumpHost, port: sshGuideEnv.ssh_port })} +

+
+
{t('dashboard.sshGuideOneShot')}
+
{guide.oneShotCommand}
+
-
{t('dashboard.sshCommand')}
-
{buildSshCommand(sshGuideEnv)}
+
{t('dashboard.sshGuideConfigExample')}
+
{guide.sshConfig}
+

{t('dashboard.sshGuideAliases', { jumpAlias: guide.jumpAlias, envAlias: guide.envAlias })}

+ ); + })()}
)} diff --git a/frontend/src/utils/sshGuide.test.ts b/frontend/src/utils/sshGuide.test.ts new file mode 100644 index 0000000..375086b --- /dev/null +++ b/frontend/src/utils/sshGuide.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { buildSshGuide, resolveSshHost, type SshGuideEnvironmentLike } from './sshGuide'; + +const baseEnv: SshGuideEnvironmentLike = { + id: 'env-1', + name: 'my-env', + ssh_port: 22001, +}; + +describe('resolveSshHost', () => { + it('returns localhost for host environments', () => { + expect(resolveSshHost(baseEnv)).toBe('127.0.0.1'); + }); + + it('uses worker base_url hostname when available', () => { + expect( + resolveSshHost({ + ...baseEnv, + worker_server_name: 'worker-a', + worker_server_base_url: 'http://10.20.30.40:8000', + }) + ).toBe('10.20.30.40'); + }); + + it('falls back to worker name when base_url is invalid', () => { + expect( + resolveSshHost({ + ...baseEnv, + worker_server_name: 'worker-b', + worker_server_base_url: 'not-a-url', + }) + ).toBe('worker-b'); + }); +}); + +describe('buildSshGuide', () => { + it('builds host guide with root fallback', () => { + const guide = buildSshGuide(baseEnv); + expect(guide.jumpHost).toBe('127.0.0.1'); + expect(guide.targetUser).toBe('root'); + expect(guide.jumpAlias).toBe('lyra-host'); + expect(guide.envAlias).toBe('lyra-env-my-env'); + expect(guide.oneShotCommand).toContain('-p 22001 root@127.0.0.1'); + expect(guide.sshConfig).toContain('ProxyJump lyra-host'); + }); + + it('uses container_user when provided', () => { + const guide = buildSshGuide({ + ...baseEnv, + container_user: 'alice', + worker_server_name: 'Worker 01', + worker_server_base_url: 'http://10.0.0.5:8000', + }); + expect(guide.targetUser).toBe('alice'); + expect(guide.jumpAlias).toBe('lyra-worker-worker-01'); + expect(guide.oneShotCommand).toContain('alice@127.0.0.1'); + }); +}); diff --git a/frontend/src/utils/sshGuide.ts b/frontend/src/utils/sshGuide.ts new file mode 100644 index 0000000..16bfcb5 --- /dev/null +++ b/frontend/src/utils/sshGuide.ts @@ -0,0 +1,74 @@ +export interface SshGuideEnvironmentLike { + id: string; + name: string; + container_user?: string; + ssh_port: number; + worker_server_name?: string | null; + worker_server_base_url?: string | null; +} + +export interface SshGuideTemplate { + jumpHost: string; + targetUser: string; + jumpAlias: string; + envAlias: string; + oneShotCommand: string; + sshConfig: string; +} + +export const resolveSshHost = (env: SshGuideEnvironmentLike): string => { + if (!env.worker_server_name) { + return '127.0.0.1'; + } + + const baseUrl = env.worker_server_base_url || ''; + if (baseUrl) { + try { + return new URL(baseUrl).hostname; + } catch { + // Fall through to worker name. + } + } + + return env.worker_server_name; +}; + +const sanitizeSshAliasPart = (value: string): string => { + const normalized = String(value || '') + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + return normalized || 'env'; +}; + +export const buildSshGuide = (env: SshGuideEnvironmentLike): SshGuideTemplate => { + const jumpHost = resolveSshHost(env); + const targetUser = env.container_user || 'root'; + const jumpAlias = env.worker_server_name + ? `lyra-worker-${sanitizeSshAliasPart(env.worker_server_name)}` + : 'lyra-host'; + const envAlias = `lyra-env-${sanitizeSshAliasPart(env.name || env.id)}`; + const oneShotCommand = `ssh -J @${jumpHost} -p ${env.ssh_port} ${targetUser}@127.0.0.1`; + const sshConfig = [ + `Host ${jumpAlias}`, + ` HostName ${jumpHost}`, + ' User ', + ' Port 22', + '', + `Host ${envAlias}`, + ' HostName 127.0.0.1', + ` Port ${env.ssh_port}`, + ` User ${targetUser}`, + ` ProxyJump ${jumpAlias}`, + ].join('\n'); + + return { + jumpHost, + targetUser, + jumpAlias, + envAlias, + oneShotCommand, + sshConfig, + }; +}; From cf75404496d95a25f5bdd38b2fdc4bbe33438edf Mon Sep 17 00:00:00 2001 From: holole Date: Sat, 21 Feb 2026 00:13:12 +0900 Subject: [PATCH 03/12] feat(dashboard): add ssh guide copy blocks and warning section --- frontend/src/i18n/locales/en/common.ts | 8 +++++ frontend/src/i18n/locales/ko/common.ts | 8 +++++ frontend/src/index.css | 8 +++++ frontend/src/pages/Dashboard.tsx | 46 ++++++++++++++++++++++---- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index 9852b8e..cc71fd0 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -99,6 +99,11 @@ const enCommon = { sshAccessGuideHostInfo: 'Target host: {{host}} · SSH port: {{port}}', sshGuideOneShot: 'One-shot command', sshGuideConfigExample: 'SSH config example', + sshGuideCopyCommand: 'Copy command', + sshGuideCopyConfig: 'Copy config', + sshGuideNotes: 'Notes', + sshGuideRootWarning: 'This guide connects as the container root account.', + sshGuidePlaceholderWarning: 'Replace with your host SSH username.', sshGuideAliases: 'Aliases: jump={{jumpAlias}}, env={{envAlias}}', sshCommand: 'SSH Command', openJupyterLab: 'Open Jupyter Lab', @@ -383,6 +388,9 @@ const enCommon = { dashboard: { sshCopied: 'SSH command copied to clipboard.', copyFailedRunManually: 'Unable to copy. Run manually: {{command}}', + sshGuideCommandCopied: 'SSH one-shot command copied.', + sshGuideConfigCopied: 'SSH config example copied.', + sshGuideCopyFailed: 'Failed to copy text. Please copy it manually.', jupyterLaunchUrlMissing: 'Unable to open Jupyter: launch URL was not returned.', jupyterOpenFailed: 'Unable to open Jupyter. Please ensure the environment is running.', codeLaunchUrlMissing: 'Unable to open code-server: launch URL was not returned.', diff --git a/frontend/src/i18n/locales/ko/common.ts b/frontend/src/i18n/locales/ko/common.ts index 202271d..ca132b6 100644 --- a/frontend/src/i18n/locales/ko/common.ts +++ b/frontend/src/i18n/locales/ko/common.ts @@ -97,6 +97,11 @@ const koCommon = { sshAccessGuideHostInfo: '대상 호스트: {{host}} · SSH 포트: {{port}}', sshGuideOneShot: '즉시 실행 명령', sshGuideConfigExample: 'SSH config 예시', + sshGuideCopyCommand: '명령 복사', + sshGuideCopyConfig: '설정 복사', + sshGuideNotes: '안내', + sshGuideRootWarning: '이 가이드는 컨테이너 root 계정으로 접속합니다.', + sshGuidePlaceholderWarning: '를 실제 호스트 SSH 사용자명으로 바꿔서 사용하세요.', sshGuideAliases: '별칭: 점프={{jumpAlias}}, 환경={{envAlias}}', sshCommand: 'SSH 명령', openJupyterLab: 'Jupyter Lab 열기', @@ -380,6 +385,9 @@ const koCommon = { dashboard: { sshCopied: 'SSH 명령을 클립보드에 복사했습니다.', copyFailedRunManually: '복사할 수 없습니다. 수동 실행: {{command}}', + sshGuideCommandCopied: 'SSH 즉시 실행 명령을 복사했습니다.', + sshGuideConfigCopied: 'SSH config 예시를 복사했습니다.', + sshGuideCopyFailed: '복사에 실패했습니다. 수동으로 복사해주세요.', jupyterLaunchUrlMissing: 'Jupyter를 열 수 없습니다: 실행 URL을 받지 못했습니다.', jupyterOpenFailed: 'Jupyter를 열 수 없습니다. 환경이 실행 중인지 확인해주세요.', codeLaunchUrlMissing: 'code-server를 열 수 없습니다: 실행 URL을 받지 못했습니다.', diff --git a/frontend/src/index.css b/frontend/src/index.css index c8cab82..d230b0f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -20,6 +20,10 @@ --primary-contrast: #ffffff; --danger: #dc2626; --success: #16a34a; + --warning-bg: #fef3c7; + --warning-border: #f59e0b; + --warning-text: #78350f; + --warning-title: #92400e; --terminal-bg: #000000; --terminal-border: #3f3f46; } @@ -37,6 +41,10 @@ --primary-contrast: #ffffff; --danger: #ef4444; --success: #22c55e; + --warning-bg: rgb(245 158 11 / 0.12); + --warning-border: rgb(245 158 11 / 0.38); + --warning-text: #fcd34d; + --warning-title: #fbbf24; --terminal-bg: #000000; --terminal-border: #3f3f46; } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b1300cc..f1445e4 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -497,6 +497,15 @@ export default function Dashboard() { } }; + const copySshGuideValue = async (value: string, successMessage: string) => { + try { + await navigator.clipboard.writeText(value); + showToast(successMessage, 'success'); + } catch { + showToast(t('feedback.dashboard.sshGuideCopyFailed'), 'error'); + } + }; + useEffect(() => { fetchEnvironments({ showLoading: true }); const interval = setInterval(() => { @@ -658,29 +667,54 @@ export default function Dashboard() { {(() => { const guide = buildSshGuide(sshGuideEnv); return ( -
-
+
+

{t('dashboard.sshAccessGuideTitle')}

-
+

{t('dashboard.sshAccessGuideFor', { name: sshGuideEnv.name })}

{t('dashboard.sshAccessGuideHostInfo', { host: guide.jumpHost, port: sshGuideEnv.ssh_port })}

-
{t('dashboard.sshGuideOneShot')}
+
+
{t('dashboard.sshGuideOneShot')}
+ +
{guide.oneShotCommand}
-
{t('dashboard.sshGuideConfigExample')}
+
+
{t('dashboard.sshGuideConfigExample')}
+ +
{guide.sshConfig}
+
+
{t('dashboard.sshGuideNotes')}
+
    +
  • {t('dashboard.sshGuideRootWarning')}
  • +
  • {t('dashboard.sshGuidePlaceholderWarning')}
  • +
+

{t('dashboard.sshGuideAliases', { jumpAlias: guide.jumpAlias, envAlias: guide.envAlias })}

-
+
+
+
{t('dashboard.rootAccountSectionTitle')}
+
{t('dashboard.rootAccountSectionDescription')}
+
+ {t('dashboard.accountRootLabel')} + setRootResetPassword(event.target.value)} + placeholder={t('dashboard.rootPasswordResetInputPlaceholder')} + className="flex-1 min-w-0 bg-[var(--bg-elevated)] border border-[var(--border)] rounded-lg px-3 py-1.5 text-sm text-[var(--text)] focus:outline-none focus:ring-1 focus:ring-blue-500" + /> + +
+

{t('dashboard.sshGuideAliases', { jumpAlias: guide.jumpAlias, envAlias: guide.envAlias })}

From 41cec66cdfbe653dc96b7ef464dfeb68fb194803 Mon Sep 17 00:00:00 2001 From: holole Date: Sat, 21 Feb 2026 00:38:03 +0900 Subject: [PATCH 07/12] feat(integration): wire ssh client settings into SSH guide rendering --- frontend/src/pages/Dashboard.tsx | 7 +++++- frontend/src/utils/sshGuide.test.ts | 18 ++++++++++++++++ frontend/src/utils/sshGuide.ts | 33 +++++++++++++++++++++++++---- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c1b9202..32cc996 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -9,6 +9,7 @@ import OverlayPortal from '../components/OverlayPortal'; import { useApp } from '../context/AppContext'; import { useToast } from '../context/ToastContext'; import { buildSshGuide } from '../utils/sshGuide'; +import { readStoredSshClientConfig } from '../utils/sshClientConfig'; interface MountConfig { host_path: string; @@ -728,7 +729,11 @@ export default function Dashboard() { {sshGuideEnv && ( {(() => { - const guide = buildSshGuide(sshGuideEnv); + const sshClient = readStoredSshClientConfig(); + const guide = buildSshGuide(sshGuideEnv, { + username: sshClient.username, + port: sshClient.port, + }); return (
diff --git a/frontend/src/utils/sshGuide.test.ts b/frontend/src/utils/sshGuide.test.ts index 375086b..d17f8b5 100644 --- a/frontend/src/utils/sshGuide.test.ts +++ b/frontend/src/utils/sshGuide.test.ts @@ -41,7 +41,20 @@ describe('buildSshGuide', () => { expect(guide.jumpAlias).toBe('lyra-host'); expect(guide.envAlias).toBe('lyra-env-my-env'); expect(guide.oneShotCommand).toContain('-p 22001 root@127.0.0.1'); + expect(guide.oneShotCommand).toContain('-J @127.0.0.1'); expect(guide.sshConfig).toContain('ProxyJump lyra-host'); + expect(guide.sshConfig).toContain('User '); + expect(guide.sshConfig).toContain('Port 22'); + }); + + it('applies saved ssh client username/port for host jump config', () => { + const guide = buildSshGuide(baseEnv, { + username: 'lyra-admin', + port: '2222', + }); + expect(guide.oneShotCommand).toContain('-J lyra-admin@127.0.0.1:2222'); + expect(guide.sshConfig).toContain('User lyra-admin'); + expect(guide.sshConfig).toContain('Port 2222'); }); it('uses container_user when provided', () => { @@ -50,9 +63,14 @@ describe('buildSshGuide', () => { container_user: 'alice', worker_server_name: 'Worker 01', worker_server_base_url: 'http://10.0.0.5:8000', + }, { + username: 'ignored-user', + port: '2022', }); expect(guide.targetUser).toBe('alice'); expect(guide.jumpAlias).toBe('lyra-worker-worker-01'); expect(guide.oneShotCommand).toContain('alice@127.0.0.1'); + expect(guide.sshConfig).toContain('User '); + expect(guide.sshConfig).toContain('Port 22'); }); }); diff --git a/frontend/src/utils/sshGuide.ts b/frontend/src/utils/sshGuide.ts index 16bfcb5..c69a5f4 100644 --- a/frontend/src/utils/sshGuide.ts +++ b/frontend/src/utils/sshGuide.ts @@ -12,10 +12,17 @@ export interface SshGuideTemplate { targetUser: string; jumpAlias: string; envAlias: string; + jumpUser: string; + jumpPort: number; oneShotCommand: string; sshConfig: string; } +export interface SshGuideClientInfo { + username?: string | null; + port?: string | number | null; +} + export const resolveSshHost = (env: SshGuideEnvironmentLike): string => { if (!env.worker_server_name) { return '127.0.0.1'; @@ -42,19 +49,35 @@ const sanitizeSshAliasPart = (value: string): string => { return normalized || 'env'; }; -export const buildSshGuide = (env: SshGuideEnvironmentLike): SshGuideTemplate => { +const parseJumpPort = (value: string | number | null | undefined): number => { + const raw = String(value ?? '').trim(); + if (!raw) return 22; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return 22; + return parsed; +}; + +export const buildSshGuide = ( + env: SshGuideEnvironmentLike, + clientInfo?: SshGuideClientInfo, +): SshGuideTemplate => { const jumpHost = resolveSshHost(env); const targetUser = env.container_user || 'root'; const jumpAlias = env.worker_server_name ? `lyra-worker-${sanitizeSshAliasPart(env.worker_server_name)}` : 'lyra-host'; const envAlias = `lyra-env-${sanitizeSshAliasPart(env.name || env.id)}`; - const oneShotCommand = `ssh -J @${jumpHost} -p ${env.ssh_port} ${targetUser}@127.0.0.1`; + const applyHostClientInfo = !env.worker_server_name; + const jumpUserCandidate = String(clientInfo?.username || '').trim(); + const jumpUser = applyHostClientInfo && jumpUserCandidate ? jumpUserCandidate : ''; + const jumpPort = applyHostClientInfo ? parseJumpPort(clientInfo?.port) : 22; + const jumpSpec = jumpPort !== 22 ? `${jumpUser}@${jumpHost}:${jumpPort}` : `${jumpUser}@${jumpHost}`; + const oneShotCommand = `ssh -J ${jumpSpec} -p ${env.ssh_port} ${targetUser}@127.0.0.1`; const sshConfig = [ `Host ${jumpAlias}`, ` HostName ${jumpHost}`, - ' User ', - ' Port 22', + ` User ${jumpUser}`, + ` Port ${jumpPort}`, '', `Host ${envAlias}`, ' HostName 127.0.0.1', @@ -68,6 +91,8 @@ export const buildSshGuide = (env: SshGuideEnvironmentLike): SshGuideTemplate => targetUser, jumpAlias, envAlias, + jumpUser, + jumpPort, oneShotCommand, sshConfig, }; From 7281033dcb970483241ba84370e9708dba1ec757 Mon Sep 17 00:00:00 2001 From: holole Date: Sat, 21 Feb 2026 00:52:53 +0900 Subject: [PATCH 08/12] fix(ssh-guide): prefer configured ssh host in jump command and config --- frontend/src/context/ToastContext.tsx | 2 +- frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/utils/sshGuide.test.ts | 5 ++++- frontend/src/utils/sshGuide.ts | 6 +++++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index 89a977d..5f1c632 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -30,7 +30,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { return ( {children} -
+
{toasts.map((toast) => ( { const sshClient = readStoredSshClientConfig(); const guide = buildSshGuide(sshGuideEnv, { + host: sshClient.host, username: sshClient.username, port: sshClient.port, }); @@ -802,7 +803,6 @@ export default function Dashboard() {
-

{t('dashboard.sshGuideAliases', { jumpAlias: guide.jumpAlias, envAlias: guide.envAlias })}

+
+ {rootResetInputError && ( +

{rootResetInputError}

+ )}
From 25c9bfd8acf88c6595e11116d21ff0c4e5253e2f Mon Sep 17 00:00:00 2001 From: holole Date: Sat, 21 Feb 2026 02:10:48 +0900 Subject: [PATCH 11/12] fix(worker): set jupyter terminal shell to zsh via terminado settings --- backend/app/tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/app/tasks.py b/backend/app/tasks.py index 65adbb7..9e1eec8 100644 --- a/backend/app/tasks.py +++ b/backend/app/tasks.py @@ -172,11 +172,11 @@ def _build_runtime_command(jupyter_mode: Optional[str], enable_jupyter: bool, en if enable_jupyter: if jupyter_mode == "python_module": script_parts.append( - 'exec python3 -m jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.token="$JUPYTER_TOKEN" --NotebookApp.token="$JUPYTER_TOKEN"' # noqa: E501 + 'exec python3 -m jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.token="$JUPYTER_TOKEN" --NotebookApp.token="$JUPYTER_TOKEN" --ServerApp.terminado_settings="{\'shell_command\': [\'$(command -v zsh || command -v bash || command -v sh || echo /bin/sh)\']}"' # noqa: E501 ) else: script_parts.append( - 'exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.token="$JUPYTER_TOKEN" --NotebookApp.token="$JUPYTER_TOKEN"' # noqa: E501 + 'exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.token="$JUPYTER_TOKEN" --NotebookApp.token="$JUPYTER_TOKEN" --ServerApp.terminado_settings="{\'shell_command\': [\'$(command -v zsh || command -v bash || command -v sh || echo /bin/sh)\']}"' # noqa: E501 ) else: script_parts.append("exec tail -f /dev/null") From d10daeec7f34f02df8ed3575fa1e201c335b96c7 Mon Sep 17 00:00:00 2001 From: holole Date: Sat, 21 Feb 2026 02:25:15 +0900 Subject: [PATCH 12/12] feat(settings): add confirmable host connection reset action --- frontend/src/i18n/locales/en/common.ts | 4 +++ frontend/src/i18n/locales/ko/common.ts | 4 +++ frontend/src/pages/Settings.tsx | 47 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index d6f029d..e351ce0 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -317,6 +317,8 @@ const enCommon = { faviconRecommended: 'Recommended: square icon (32x32 or 64x64), max 512KB.', hostServerTitle: 'Host Server Connection', hostServerDescription: 'Configure SSH access to the host machine for the Terminal tab.', + sshResetConfirmTitle: 'Reset host server connection?', + sshResetConfirmMessage: 'This will clear saved SSH credentials and key metadata in this browser.', hostAddress: 'Host Address', autoDetected: 'Auto-detected', port: 'Port', @@ -452,6 +454,8 @@ const enCommon = { sshKeyFileRequired: 'Please select an SSH key file.', sshSettingsUpdated: 'SSH settings updated! Key is encrypted in your browser.', sshSettingsUpdateFailed: 'Failed to update SSH settings.', + sshSettingsReset: 'SSH settings reset.', + sshSettingsResetFailed: 'Failed to reset SSH settings.', sshTesting: 'Testing connection...', sshPickKeyToTest: 'Please pick a key file to test.', sshConnectionSuccess: 'Connection Successful!', diff --git a/frontend/src/i18n/locales/ko/common.ts b/frontend/src/i18n/locales/ko/common.ts index 97d8247..bc66264 100644 --- a/frontend/src/i18n/locales/ko/common.ts +++ b/frontend/src/i18n/locales/ko/common.ts @@ -314,6 +314,8 @@ const koCommon = { faviconRecommended: '권장: 정사각형 아이콘 (32x32 또는 64x64), 최대 512KB.', hostServerTitle: '호스트 서버 연결', hostServerDescription: '터미널 탭에서 사용할 호스트 SSH 연결을 설정합니다.', + sshResetConfirmTitle: '호스트 서버 연결을 초기화할까요?', + sshResetConfirmMessage: '이 브라우저에 저장된 SSH 인증 정보와 키 메타데이터가 삭제됩니다.', hostAddress: '호스트 주소', autoDetected: '자동 감지', port: '포트', @@ -449,6 +451,8 @@ const koCommon = { sshKeyFileRequired: 'SSH 키 파일을 선택해주세요.', sshSettingsUpdated: 'SSH 설정이 업데이트되었습니다. 키는 브라우저에서 암호화됩니다.', sshSettingsUpdateFailed: 'SSH 설정 업데이트에 실패했습니다.', + sshSettingsReset: 'SSH 설정이 초기화되었습니다.', + sshSettingsResetFailed: 'SSH 설정 초기화에 실패했습니다.', sshTesting: '연결을 테스트하는 중...', sshPickKeyToTest: '테스트할 키 파일을 선택해주세요.', sshConnectionSuccess: '연결 성공!', diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 47a212e..e018419 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -10,6 +10,7 @@ import OverlayPortal from '../components/OverlayPortal'; import Modal from '../components/Modal'; import { getStoredUserName, setStoredUserName } from '../utils/userIdentity'; import { + clearStoredSshClientConfig, isSshClientConfigReady, readStoredSshClientConfig, toSshConnectPayload, @@ -110,6 +111,7 @@ export default function Settings() { const [tmuxLoading, setTmuxLoading] = useState(false); const [resourceCleanupTarget, setResourceCleanupTarget] = useState(null); const [workerCleanupTarget, setWorkerCleanupTarget] = useState(null); + const [sshResetConfirmOpen, setSshResetConfirmOpen] = useState(false); const fileInputRef = useRef(null); const faviconInputRef = useRef(null); @@ -494,6 +496,34 @@ export default function Settings() { } }; + const handleResetSsh = () => { + try { + setSshResetConfirmOpen(false); + clearStoredSshClientConfig(); + localStorage.removeItem('ssh_private_key_encrypted'); + localStorage.removeItem('ssh_key_name'); + if (fileInputRef.current) fileInputRef.current.value = ''; + + setSshSettings({ + port: '22', + username: '', + authMethod: 'password', + password: '', + privateKey: '', + keyName: '', + masterPassword: '', + }); + setTmuxSessions([]); + setSelectedTmuxSessions([]); + setSessionStatus({ type: 'idle' }); + setSshStatus({ type: 'success', message: t('feedback.settings.sshSettingsReset') }); + setTimeout(() => setSshStatus({ type: 'idle' }), 3000); + } catch (error) { + console.error(error); + setSshStatus({ type: 'error', message: t('feedback.settings.sshSettingsResetFailed') }); + } + }; + const handleTestSsh = async () => { try { setSshStatus({ type: 'loading', message: t('feedback.settings.sshTesting') }); @@ -962,6 +992,16 @@ export default function Settings() { return (
+ setSshResetConfirmOpen(false)} + onConfirm={handleResetSsh} + title={t('settings.sshResetConfirmTitle')} + message={t('settings.sshResetConfirmMessage')} + type="confirm" + confirmText={t('actions.confirm')} + cancelText={t('actions.cancel')} + /> setResourceCleanupTarget(null)} @@ -1308,6 +1348,13 @@ export default function Settings() { )}
+