mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
release-2.4.1 (#921)
* chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910) Bumps the dev-minor-updates group with 12 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` | | [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` | | [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` | | [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` | | [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` | | [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` | Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `cytoscape` from 3.33.4 to 3.34.0 - [Release notes](https://github.com/cytoscape/cytoscape.js/releases) - [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0) Updates `electron` from 42.2.0 to 42.4.1 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1) Updates `electron-builder` from 26.8.1 to 26.15.3 - [Release notes](https://github.com/electron-userland/electron-builder/releases) - [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md) - [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder) Updates `lucide-react` from 1.16.0 to 1.20.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react) Updates `radix-ui` from 1.4.3 to 1.6.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `react-hook-form` from 7.76.1 to 7.79.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0) Updates `sharp` from 0.34.5 to 0.35.1 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1) Updates `typescript-eslint` from 8.60.0 to 8.61.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: cytoscape dependency-version: 3.34.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron dependency-version: 42.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron-builder dependency-version: 26.15.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: lucide-react dependency-version: 1.18.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: radix-ui dependency-version: 1.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-hook-form dependency-version: 7.79.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: sharp dependency-version: 0.35.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.61.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump node in /docker in the docker-major-updates group (#914) Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump the github-actions group with 2 updates (#915) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916) Bumps the prod-minor-updates group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` | | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` | | [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` | | [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` | | [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` | Updates `axios` from 1.17.0 to 1.18.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0) Updates `better-sqlite3` from 12.10.0 to 12.11.1 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1) Updates `body-parser` from 2.2.2 to 2.3.0 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) Updates `multer` from 2.1.1 to 2.2.0 - [Release notes](https://github.com/expressjs/multer/releases) - [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md) - [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0) Updates `undici` from 8.4.0 to 8.5.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: better-sqlite3 dependency-version: 12.11.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: multer dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: add unlink button for dual auth * fix: general bug fixes * fix: general qol/small feature additions * fix: 100mb max upload size * fix: terminal syntax not handling carriage returns or shell prompt lines properly * feat: continued general improvements * fix: terminal outputting success right after folder path * chore: update release notes * feat: continued fixes and improvements * chore: lint, format, and bump version to 2.4.1 * chore: sync Crowdin translations for 2.4.1 * fix: rebase dev branch onto main before Crowdin download * fix: use merge instead of rebase to sync main before Crowdin --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
+272
-5
@@ -36,6 +36,7 @@ import type {
|
||||
FontSizeId,
|
||||
} from "@/types/ui-types";
|
||||
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
|
||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
getSSHHosts,
|
||||
@@ -80,7 +81,7 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType),
|
||||
enableTerminal: h.enableTerminal ?? true,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableFileManager: h.enableFileManager ?? true,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableProxmox: h.enableProxmox ?? false,
|
||||
enableTmuxMonitor: h.enableTmuxMonitor ?? false, // --- tmux-monitor ---
|
||||
@@ -108,6 +109,9 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
socks5Username: h.socks5Username,
|
||||
socks5Password: h.socks5Password,
|
||||
socks5ProxyChain: h.socks5ProxyChain ?? [],
|
||||
statsConfig: (typeof h.statsConfig === "string"
|
||||
? JSON.parse(h.statsConfig)
|
||||
: h.statsConfig) as Host["statsConfig"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,9 +165,11 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
||||
export function AppShell({
|
||||
username,
|
||||
onLogout,
|
||||
onChangeServer,
|
||||
}: {
|
||||
username: string;
|
||||
onLogout: () => void;
|
||||
onChangeServer?: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
@@ -193,6 +199,9 @@ export function AppShell({
|
||||
JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
|
||||
Array(6).fill(null),
|
||||
);
|
||||
useEffect(() => {
|
||||
paneTabIdsRef.current = paneTabIds;
|
||||
}, [paneTabIds]);
|
||||
const [focusedPaneIndex, setFocusedPaneIndex] = useState<number | null>(null);
|
||||
const [realHostTree, setRealHostTree] = useState<HostFolder | null>(null);
|
||||
const [hostsLoading, setHostsLoading] = useState(true);
|
||||
@@ -204,7 +213,6 @@ export function AppShell({
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [railView, setRailView] = useState<RailView>("hosts");
|
||||
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem("termix_sidebarWidth");
|
||||
return saved ? parseInt(saved, 10) : 291;
|
||||
@@ -243,6 +251,26 @@ export function AppShell({
|
||||
}, []);
|
||||
|
||||
const lastShiftTime = useRef(0);
|
||||
const tabsRef = useRef(tabs);
|
||||
const activeTabIdRef = useRef(activeTabId);
|
||||
const splitModeRef = useRef(splitMode);
|
||||
const focusedPaneIndexRef = useRef<number | null>(null);
|
||||
const paneContentElsRef = useRef<(HTMLDivElement | null)[]>(
|
||||
Array(6).fill(null),
|
||||
);
|
||||
const paneTabIdsRef = useRef<(string | null)[]>(Array(6).fill(null));
|
||||
useEffect(() => {
|
||||
tabsRef.current = tabs;
|
||||
}, [tabs]);
|
||||
useEffect(() => {
|
||||
activeTabIdRef.current = activeTabId;
|
||||
}, [activeTabId]);
|
||||
useEffect(() => {
|
||||
splitModeRef.current = splitMode;
|
||||
}, [splitMode]);
|
||||
useEffect(() => {
|
||||
focusedPaneIndexRef.current = focusedPaneIndex;
|
||||
}, [focusedPaneIndex]);
|
||||
const [commandPaletteShortcutEnabled, setCommandPaletteShortcutEnabled] =
|
||||
useState<boolean>(() => {
|
||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
@@ -254,6 +282,9 @@ export function AppShell({
|
||||
const [paneContentEls, setPaneContentEls] = useState<
|
||||
(HTMLDivElement | null)[]
|
||||
>(Array(6).fill(null));
|
||||
useEffect(() => {
|
||||
paneContentElsRef.current = paneContentEls;
|
||||
}, [paneContentEls]);
|
||||
|
||||
// Stable per-tab DOM nodes — created once per tab, never destroyed while the tab lives.
|
||||
// We always portal each tab's content into its own node, then move that node between
|
||||
@@ -314,6 +345,174 @@ export function AppShell({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [commandPaletteShortcutEnabled]);
|
||||
|
||||
// Split-screen and tab navigation hotkeys
|
||||
// Also registered in globalShortcutHandler so xterm can invoke directly
|
||||
// without going through synthetic DOM events (which are unreliable).
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl+Shift+\ — toggle 2-way split (side by side)
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Backslash") {
|
||||
e.preventDefault();
|
||||
if (splitModeRef.current !== "none") {
|
||||
splitModeRef.current = "none";
|
||||
setSplitMode("none");
|
||||
setPaneTabIds(Array(6).fill(null));
|
||||
} else {
|
||||
const mode = "2-way";
|
||||
splitModeRef.current = mode;
|
||||
const currentTabs = tabsRef.current;
|
||||
const currentActiveId = activeTabIdRef.current;
|
||||
const count = PANE_COUNTS[mode];
|
||||
const next: (string | null)[] = Array(6).fill(null);
|
||||
next[0] = currentActiveId;
|
||||
let slot = 1;
|
||||
for (const tab of currentTabs) {
|
||||
if (slot >= count) break;
|
||||
if (tab.id !== currentActiveId && tab.type !== "dashboard") {
|
||||
next[slot] = tab.id;
|
||||
slot++;
|
||||
}
|
||||
}
|
||||
setSplitMode(mode);
|
||||
setPaneTabIds(next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+- — toggle 3-way-horizontal split (top/bottom)
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && e.code === "Minus") {
|
||||
e.preventDefault();
|
||||
if (splitModeRef.current !== "none") {
|
||||
splitModeRef.current = "none";
|
||||
setSplitMode("none");
|
||||
setPaneTabIds(Array(6).fill(null));
|
||||
} else {
|
||||
const mode = "3-way-horizontal";
|
||||
splitModeRef.current = mode;
|
||||
const currentTabs = tabsRef.current;
|
||||
const currentActiveId = activeTabIdRef.current;
|
||||
const count = PANE_COUNTS[mode];
|
||||
const next: (string | null)[] = Array(6).fill(null);
|
||||
next[0] = currentActiveId;
|
||||
let slot = 1;
|
||||
for (const tab of currentTabs) {
|
||||
if (slot >= count) break;
|
||||
if (tab.id !== currentActiveId && tab.type !== "dashboard") {
|
||||
next[slot] = tab.id;
|
||||
slot++;
|
||||
}
|
||||
}
|
||||
setSplitMode(mode);
|
||||
setPaneTabIds(next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+Arrow — navigate between panes in split mode
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
||||
if (
|
||||
e.code === "ArrowLeft" ||
|
||||
e.code === "ArrowRight" ||
|
||||
e.code === "ArrowUp" ||
|
||||
e.code === "ArrowDown"
|
||||
) {
|
||||
if (splitModeRef.current === "none") return;
|
||||
const count = PANE_COUNTS[splitModeRef.current];
|
||||
if (count < 2) return;
|
||||
e.preventDefault();
|
||||
const current = focusedPaneIndexRef.current ?? 0;
|
||||
const mode = splitModeRef.current;
|
||||
const dir = e.code;
|
||||
|
||||
// Layout-aware navigation maps: [left, right, up, down] per pane index.
|
||||
// null means no movement in that direction.
|
||||
const navMap: Record<string, (number | null)[][]> = {
|
||||
"2-way": [
|
||||
[null, 1, null, null],
|
||||
[0, null, null, null],
|
||||
],
|
||||
"3-way": [
|
||||
[null, 1, null, null],
|
||||
[0, null, null, 2],
|
||||
[0, null, 1, null],
|
||||
],
|
||||
"3-way-horizontal": [
|
||||
[null, 1, null, 2],
|
||||
[0, null, null, 2],
|
||||
[null, null, 0, null],
|
||||
],
|
||||
"4-way": [
|
||||
[null, 1, null, 2],
|
||||
[0, null, null, 3],
|
||||
[null, 3, 0, null],
|
||||
[2, null, 1, null],
|
||||
],
|
||||
"5-way": [
|
||||
[null, 1, null, 3],
|
||||
[0, 2, null, 4],
|
||||
[1, null, null, 4],
|
||||
[null, 4, 0, null],
|
||||
[3, null, 1, null],
|
||||
],
|
||||
"6-way": [
|
||||
[null, 1, null, 3],
|
||||
[0, 2, null, 4],
|
||||
[1, null, null, 5],
|
||||
[null, 4, 0, null],
|
||||
[3, 5, 1, null],
|
||||
[4, null, 2, null],
|
||||
],
|
||||
};
|
||||
|
||||
const paneNav = navMap[mode]?.[current];
|
||||
const dirIndex =
|
||||
{ ArrowLeft: 0, ArrowRight: 1, ArrowUp: 2, ArrowDown: 3 }[dir] ??
|
||||
-1;
|
||||
const next = paneNav?.[dirIndex] ?? null;
|
||||
if (next === null) return;
|
||||
|
||||
focusedPaneIndexRef.current = next;
|
||||
setFocusedPaneIndex(next);
|
||||
// Physically move DOM focus into the target pane's terminal
|
||||
const tabId = paneTabIdsRef.current[next];
|
||||
if (tabId) {
|
||||
const termRef = terminalRefs.current.get(tabId);
|
||||
(
|
||||
termRef?.current as
|
||||
| import("@/features/terminal/Terminal").TerminalHandle
|
||||
| null
|
||||
)?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Shift+] / Ctrl+Shift+[ — cycle through open tabs (] = next, [ = previous)
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||
if (e.code === "BracketRight" || e.code === "BracketLeft") {
|
||||
e.preventDefault();
|
||||
const currentTabs = tabsRef.current;
|
||||
if (currentTabs.length < 2) return;
|
||||
const currentId = activeTabIdRef.current;
|
||||
const idx = currentTabs.findIndex((t) => t.id === currentId);
|
||||
const next =
|
||||
e.code === "BracketRight"
|
||||
? (idx + 1) % currentTabs.length
|
||||
: (idx - 1 + currentTabs.length) % currentTabs.length;
|
||||
setActiveTabId(currentTabs[next].id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
globalShortcutHandler.current = handleKeyDown;
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
globalShortcutHandler.current = null;
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
@@ -623,11 +822,17 @@ export function AppShell({
|
||||
const restoredSessionId =
|
||||
liveSession?.sessionId ?? saved.backendSessionId ?? null;
|
||||
|
||||
const isCustomLabel =
|
||||
host &&
|
||||
saved.label !== host.name &&
|
||||
!/^.+ \(\d+\)$/.test(saved.label);
|
||||
|
||||
restoredTabs.push({
|
||||
id: tabId,
|
||||
instanceId: saved.id,
|
||||
type: saved.tabType as TabType,
|
||||
label: saved.label,
|
||||
customLabel: isCustomLabel ? saved.label : undefined,
|
||||
host,
|
||||
openedAt: new Date(saved.createdAt).getTime(),
|
||||
restoredSessionId,
|
||||
@@ -694,7 +899,12 @@ export function AppShell({
|
||||
const openTab = useCallback(function openTab(
|
||||
host: Host,
|
||||
type: TabType,
|
||||
restore?: { instanceId: string; restoredSessionId: string | null },
|
||||
restore?: {
|
||||
instanceId: string;
|
||||
restoredSessionId: string | null;
|
||||
savedLabel?: string;
|
||||
initialFilePath?: string;
|
||||
},
|
||||
) {
|
||||
const tabId = `${host.name}-${type}-${Date.now()}`;
|
||||
const instanceId =
|
||||
@@ -707,7 +917,34 @@ export function AppShell({
|
||||
if (ref) terminalRefs.current.set(tabId, ref);
|
||||
|
||||
let finalLabel = host.name;
|
||||
const savedLabel = restore?.savedLabel;
|
||||
const initialFilePath = restore?.initialFilePath;
|
||||
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
|
||||
const isCustomLabel =
|
||||
savedLabel != null &&
|
||||
savedLabel !== host.name &&
|
||||
!/^.+ \(\d+\)$/.test(savedLabel);
|
||||
|
||||
setTabs((prev) => {
|
||||
if (isCustomLabel && savedLabel) {
|
||||
finalLabel = savedLabel;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: tabId,
|
||||
instanceId,
|
||||
type,
|
||||
label: finalLabel,
|
||||
customLabel: finalLabel,
|
||||
host,
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
initialFilePath,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const same = prev.filter(
|
||||
(t) =>
|
||||
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
|
||||
@@ -734,6 +971,7 @@ export function AppShell({
|
||||
openedAt,
|
||||
terminalRef: ref,
|
||||
restoredSessionId: restore?.restoredSessionId ?? null,
|
||||
initialFilePath,
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -789,6 +1027,9 @@ export function AppShell({
|
||||
),
|
||||
0,
|
||||
);
|
||||
} else if (pendingEvent === "host-manager:show-credentials") {
|
||||
setSidebarOpen(true);
|
||||
setRailView("credentials");
|
||||
} else {
|
||||
setSidebarOpen(true);
|
||||
setRailView("hosts");
|
||||
@@ -919,6 +1160,18 @@ export function AppShell({
|
||||
doCloseTab(id);
|
||||
}
|
||||
|
||||
function renameTab(tabId: string, newLabel: string) {
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === tabId ? { ...t, customLabel: newLabel, label: newLabel } : t,
|
||||
),
|
||||
);
|
||||
const tab = tabs.find((t) => t.id === tabId);
|
||||
if (tab?.instanceId) {
|
||||
patchOpenTab(tab.instanceId, { label: newLabel }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function splitTabQuick(tabId: string, mode: SplitMode) {
|
||||
setSplitMode(mode);
|
||||
setPaneTabIds(() => {
|
||||
@@ -1182,6 +1435,7 @@ export function AppShell({
|
||||
openTab(host, record.tabType as TabType, {
|
||||
instanceId: record.id,
|
||||
restoredSessionId: effectiveSessionId,
|
||||
savedLabel: record.label,
|
||||
});
|
||||
} else {
|
||||
openSingletonTab(record.tabType as TabType);
|
||||
@@ -1193,6 +1447,8 @@ export function AppShell({
|
||||
prev.filter((r) => r.id !== recordId),
|
||||
);
|
||||
}}
|
||||
onRenameTab={renameTab}
|
||||
onReorderTabs={setTabs}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1208,6 +1464,7 @@ export function AppShell({
|
||||
<UserProfilePanel
|
||||
username={username}
|
||||
onLogout={onLogout}
|
||||
onChangeServer={onChangeServer}
|
||||
userPrefs={userPrefs}
|
||||
onPrefsChange={setUserPrefs}
|
||||
/>
|
||||
@@ -1264,8 +1521,6 @@ export function AppShell({
|
||||
splitMode={splitMode}
|
||||
username={username}
|
||||
isAdmin={isAdmin}
|
||||
profileDropdownOpen={profileDropdownOpen}
|
||||
onProfileDropdownChange={setProfileDropdownOpen}
|
||||
onRailClick={handleRailClick}
|
||||
onOpenTab={openSingletonTab}
|
||||
onLogout={onLogout}
|
||||
@@ -1334,6 +1589,7 @@ export function AppShell({
|
||||
onSplitTab={splitTabQuick}
|
||||
onAddToSplit={addTabToSplit}
|
||||
onRemoveFromSplit={removeTabFromSplit}
|
||||
onRenameTab={renameTab}
|
||||
/>
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||
@@ -1388,6 +1644,17 @@ export function AppShell({
|
||||
openTab,
|
||||
closeTab,
|
||||
inPane || activeInline,
|
||||
(host, filePath) =>
|
||||
openTab(host, "files", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: filePath,
|
||||
}),
|
||||
(host, path) => openTab(host, "files"),
|
||||
renameTab,
|
||||
),
|
||||
tabNode,
|
||||
tab.id,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare";
|
||||
|
||||
export type AcmeSettings = {
|
||||
enabled: boolean;
|
||||
domain: string;
|
||||
email: string;
|
||||
challengeType: AcmeChallengeType;
|
||||
cloudflareToken: string;
|
||||
lastIssuedAt: string | null;
|
||||
certStatus: "none" | "valid" | "expiring" | "expired";
|
||||
certExpiresAt: string | null;
|
||||
};
|
||||
|
||||
export async function getAcmeSslSettings(): Promise<AcmeSettings> {
|
||||
try {
|
||||
const response = await authApi.get("/users/acme-ssl-settings");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch ACME SSL settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAcmeSslSettings(
|
||||
settings: Partial<
|
||||
Omit<AcmeSettings, "certStatus" | "certExpiresAt" | "lastIssuedAt">
|
||||
>,
|
||||
): Promise<AcmeSettings> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/acme-ssl-settings", settings);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update ACME SSL settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestAcmeCertificate(): Promise<
|
||||
AcmeSettings & { success: boolean }
|
||||
> {
|
||||
try {
|
||||
const response = await authApi.post("/users/acme-ssl-request", {});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "request ACME certificate");
|
||||
}
|
||||
}
|
||||
@@ -81,3 +81,56 @@ export async function resetRecentActivity(): Promise<{ message: string }> {
|
||||
throw handleApiError(error, "reset recent activity");
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceLink {
|
||||
id: number;
|
||||
userId: string;
|
||||
label: string;
|
||||
url: string;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function getServiceLinks(): Promise<ServiceLink[]> {
|
||||
try {
|
||||
const response = await dashboardApi.get("/service-links");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch service links");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createServiceLink(
|
||||
label: string,
|
||||
url: string,
|
||||
): Promise<ServiceLink> {
|
||||
try {
|
||||
const response = await dashboardApi.post("/service-links", { label, url });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create service link");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteServiceLink(
|
||||
id: number,
|
||||
): Promise<{ message: string }> {
|
||||
try {
|
||||
const response = await dashboardApi.delete(`/service-links/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete service link");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateServiceLink(
|
||||
id: number,
|
||||
updates: { label?: string; url?: string },
|
||||
): Promise<ServiceLink> {
|
||||
try {
|
||||
const response = await dashboardApi.put(`/service-links/${id}`, updates);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update service link");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface UserPreferences {
|
||||
disableUpdateCheck?: boolean | null;
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
|
||||
@@ -146,3 +146,43 @@ export async function updateGuacamoleSettings(settings: {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST DEFAULTS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export type HostDefaults = {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
credentialId?: number | null;
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
theme?: string;
|
||||
cursorStyle?: string;
|
||||
cursorBlink?: boolean;
|
||||
enableSessionLogging?: boolean;
|
||||
enableCommandHistory?: boolean;
|
||||
};
|
||||
|
||||
export async function getHostDefaults(): Promise<HostDefaults> {
|
||||
try {
|
||||
const response = await authApi.get("/users/host-defaults");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHostDefaults(
|
||||
defaults: HostDefaults,
|
||||
): Promise<HostDefaults> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/host-defaults", defaults);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,3 +181,52 @@ export async function reorderSnippets(
|
||||
throw handleApiError(error, "reorder snippets");
|
||||
}
|
||||
}
|
||||
|
||||
export interface SnippetExportData {
|
||||
snippets: Array<{
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number;
|
||||
hostFilter?: string | null;
|
||||
}>;
|
||||
folders: Array<{
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function exportSnippets(): Promise<SnippetExportData> {
|
||||
try {
|
||||
const response = await authApi.get("/snippets/export");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "export snippets");
|
||||
}
|
||||
}
|
||||
|
||||
export async function importSnippets(
|
||||
data: SnippetExportData,
|
||||
overwrite: boolean,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
snippetsImported: number;
|
||||
snippetsSkipped: number;
|
||||
snippetsUpdated: number;
|
||||
foldersImported: number;
|
||||
foldersSkipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets/bulk-import", {
|
||||
...data,
|
||||
overwrite,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "import snippets");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,11 @@ export async function connectSSH(
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect", {
|
||||
sessionId,
|
||||
...config,
|
||||
});
|
||||
const response = await fileManagerApi.post(
|
||||
"/ssh/connect",
|
||||
{ sessionId, ...config },
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
@@ -344,18 +345,20 @@ export async function uploadSSHFile(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
fileName: string,
|
||||
content: string,
|
||||
file: File,
|
||||
hostId?: number,
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/uploadFile", {
|
||||
sessionId,
|
||||
path,
|
||||
fileName,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
const form = new FormData();
|
||||
form.append("sessionId", sessionId);
|
||||
form.append("path", path);
|
||||
if (hostId !== undefined) form.append("hostId", String(hostId));
|
||||
if (userId !== undefined) form.append("userId", userId);
|
||||
form.append("file", file, fileName);
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/uploadFileStream", form, {
|
||||
timeout: 0,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -370,12 +373,16 @@ export async function downloadSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/downloadFile", {
|
||||
sessionId,
|
||||
path: filePath,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await fileManagerApi.post(
|
||||
"/ssh/downloadFile",
|
||||
{
|
||||
sessionId,
|
||||
path: filePath,
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
{ timeout: 0 },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "download SSH file");
|
||||
@@ -389,7 +396,7 @@ export async function downloadSSHFileStream(
|
||||
const response = await fileManagerApi.post(
|
||||
"/ssh/downloadFileStream",
|
||||
{ sessionId, path: filePath },
|
||||
{ responseType: "blob" },
|
||||
{ responseType: "blob", timeout: 0 },
|
||||
);
|
||||
const blob = response.data as Blob;
|
||||
const fileName = filePath.split("/").pop() || "download";
|
||||
|
||||
@@ -107,6 +107,28 @@ export async function bulkImportSSHHosts(
|
||||
}
|
||||
}
|
||||
|
||||
export async function importSSHConfigHosts(
|
||||
content: string,
|
||||
overwrite = false,
|
||||
): Promise<{
|
||||
message: string;
|
||||
success: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/ssh-config-import", {
|
||||
content,
|
||||
overwrite,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "import SSH config hosts");
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverProxmoxGuests(
|
||||
hostId: number,
|
||||
): Promise<ProxmoxDiscoverResult> {
|
||||
|
||||
@@ -196,6 +196,30 @@ export async function updateOidcAutoProvision(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOidcSilentLoginDefault(): Promise<{
|
||||
enabled: boolean;
|
||||
}> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc-silent-login-default");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get OIDC silent login default");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOidcSilentLoginDefault(
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/oidc-silent-login-default", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update OIDC silent login default");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePasswordLoginAllowed(
|
||||
allowed: boolean,
|
||||
): Promise<{ allowed: boolean }> {
|
||||
|
||||
+66
-9
@@ -34,6 +34,7 @@ import {
|
||||
isElectron,
|
||||
getEmbeddedServerStatus,
|
||||
getCurrentToken,
|
||||
getOidcSilentLoginDefault,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
@@ -256,6 +257,9 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [ldapUsername, setLdapUsername] = useState("");
|
||||
const [ldapPassword, setLdapPassword] = useState("");
|
||||
const silentSigninHandledRef = useRef(false);
|
||||
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
|
||||
const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
|
||||
useState(false);
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
const [dbHealthChecking, setDbHealthChecking] = useState(true);
|
||||
@@ -288,6 +292,10 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
.then((providers) => setSsoProviders(providers || []))
|
||||
.catch(() => setSsoProviders([]))
|
||||
.finally(() => setSsoProvidersLoaded(true));
|
||||
getOidcSilentLoginDefault()
|
||||
.then((res) => setOidcSilentLoginDefault(res.enabled))
|
||||
.catch(() => {})
|
||||
.finally(() => setOidcSilentLoginDefaultLoaded(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -312,6 +320,18 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
return;
|
||||
}
|
||||
if (isElectron()) {
|
||||
const forceShow = localStorage.getItem("termix_show_server_config");
|
||||
if (forceShow === "true") {
|
||||
localStorage.removeItem("termix_show_server_config");
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
setCurrentServerUrl(config?.serverUrl || "");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setShowServerConfig(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [config, status] = await Promise.all([
|
||||
getServerConfig(),
|
||||
@@ -732,6 +752,29 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const loadingKey = providerId ?? -1;
|
||||
setProviderLoading((prev) => ({ ...prev, [loadingKey]: true }));
|
||||
try {
|
||||
if (isInElectronWebView()) {
|
||||
// Inside the Electron iframe: delegate OIDC to the parent window so
|
||||
// the system browser opens instead of navigating the iframe (which
|
||||
// would break captcha stages like Cloudflare Turnstile).
|
||||
const callbackPort = 17832 + Math.floor(Math.random() * 100);
|
||||
const authResponse = await getOIDCAuthorizeUrl(
|
||||
rememberMe,
|
||||
callbackPort,
|
||||
providerId,
|
||||
);
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
if (!authUrl) throw new Error(t("errors.invalidAuthUrl"));
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "OIDC_SYSTEM_BROWSER_AUTH",
|
||||
source: "oidc_request",
|
||||
authUrl,
|
||||
callbackPort,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isElectron()) {
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
@@ -834,14 +877,19 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
|
||||
if (!shouldTriggerSilentSignin(window.location.search)) return;
|
||||
if (!oidcSilentLoginDefaultLoaded) return;
|
||||
|
||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
||||
);
|
||||
const urlTriggered = shouldTriggerSilentSignin(window.location.search);
|
||||
if (!urlTriggered && !oidcSilentLoginDefault) return;
|
||||
|
||||
if (urlTriggered) {
|
||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
silentSigninHandledRef.current = true;
|
||||
|
||||
@@ -853,8 +901,17 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||
}, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]);
|
||||
if (urlTriggered) {
|
||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||
}
|
||||
}, [
|
||||
handleOIDCLogin,
|
||||
ssoProvidersLoaded,
|
||||
ssoProviders,
|
||||
t,
|
||||
oidcSilentLoginDefault,
|
||||
oidcSilentLoginDefaultLoaded,
|
||||
]);
|
||||
|
||||
// Electron server config / webview auth success screens
|
||||
if (isElectron() && !isInElectronWebView()) {
|
||||
|
||||
@@ -63,13 +63,43 @@ export function ElectronLoginForm({
|
||||
try {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
const { type, platform, source, token } = event.data;
|
||||
const { type, platform, source, token, authUrl, callbackPort } =
|
||||
event.data;
|
||||
|
||||
if (
|
||||
type === "AUTH_SUCCESS" &&
|
||||
platform === "desktop" &&
|
||||
AUTH_MESSAGE_SOURCES.has(source)
|
||||
) {
|
||||
await handleAuthSuccess(token ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
// OIDC login requested from inside the iframe — open the system browser
|
||||
// so captcha stages (e.g. Cloudflare Turnstile) render correctly.
|
||||
if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) {
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: {
|
||||
oidcSystemBrowserAuth?: (
|
||||
url: string,
|
||||
port: number,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
token?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
).electronAPI;
|
||||
if (!electronAPI?.oidcSystemBrowserAuth) return;
|
||||
const result = await electronAPI.oidcSystemBrowserAuth(
|
||||
authUrl,
|
||||
callbackPort,
|
||||
);
|
||||
if (result.success && result.token) {
|
||||
await handleAuthSuccess(result.token);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
@@ -12,7 +12,34 @@ import {
|
||||
setEmbeddedMode,
|
||||
type ServerConfig,
|
||||
} from "@/main-axios.ts";
|
||||
import { Server, Monitor, Loader2 } from "lucide-react";
|
||||
import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react";
|
||||
|
||||
const SAVED_URLS_KEY = "termix_saved_server_urls";
|
||||
const MAX_SAVED_URLS = 5;
|
||||
|
||||
function getSavedUrls(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVED_URLS_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function addSavedUrl(url: string) {
|
||||
const urls = getSavedUrls().filter((u) => u !== url);
|
||||
urls.unshift(url);
|
||||
localStorage.setItem(
|
||||
SAVED_URLS_KEY,
|
||||
JSON.stringify(urls.slice(0, MAX_SAVED_URLS)),
|
||||
);
|
||||
}
|
||||
|
||||
function removeSavedUrl(url: string) {
|
||||
const urls = getSavedUrls().filter((u) => u !== url);
|
||||
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
|
||||
}
|
||||
|
||||
interface ServerConfigProps {
|
||||
onServerConfigured: (serverUrl: string) => void;
|
||||
@@ -36,12 +63,31 @@ export function ElectronServerConfig({
|
||||
const [embeddedAvailable, setEmbeddedAvailable] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [savedUrls, setSavedUrls] = useState<string[]>([]);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadServerConfig();
|
||||
checkEmbeddedBackend();
|
||||
setSavedUrls(getSavedUrls());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const loadServerConfig = async () => {
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
@@ -151,6 +197,8 @@ export function ElectronServerConfig({
|
||||
const success = await saveServerConfig(config);
|
||||
|
||||
if (success) {
|
||||
addSavedUrl(normalizedUrl);
|
||||
setSavedUrls(getSavedUrls());
|
||||
onServerConfigured(normalizedUrl);
|
||||
} else {
|
||||
setError(t("serverConfig.saveFailed"));
|
||||
@@ -220,14 +268,70 @@ export function ElectronServerConfig({
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
disabled={loading || embeddedLoading}
|
||||
/>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
disabled={loading || embeddedLoading}
|
||||
className={savedUrls.length > 0 ? "pr-9" : ""}
|
||||
onFocus={() => {
|
||||
if (savedUrls.length > 0) setDropdownOpen(true);
|
||||
}}
|
||||
/>
|
||||
{savedUrls.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
disabled={loading || embeddedLoading}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("serverConfig.savedServers")}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{dropdownOpen && savedUrls.length > 0 && (
|
||||
<div className="absolute z-50 w-full top-full mt-1 border border-border bg-card shadow-md">
|
||||
<p className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border">
|
||||
{t("serverConfig.savedServers")}
|
||||
</p>
|
||||
{savedUrls.map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center justify-between group hover:bg-muted transition-colors"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left px-2.5 py-2 text-sm font-mono truncate"
|
||||
onClick={() => {
|
||||
handleUrlChange(url);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-2 text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
title={t("serverConfig.removeServer")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSavedUrl(url);
|
||||
const updated = getSavedUrls();
|
||||
setSavedUrls(updated);
|
||||
if (updated.length === 0) setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverUrl.trim().startsWith("https://") && (
|
||||
|
||||
+174
-107
@@ -29,6 +29,7 @@ import {
|
||||
isElectron,
|
||||
getEmbeddedServerStatus,
|
||||
getCurrentToken,
|
||||
getOidcSilentLoginDefault,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
@@ -129,6 +130,9 @@ export function Auth({
|
||||
const [ldapPassword, setLdapPassword] = useState("");
|
||||
const [ldapLoading, setLdapLoading] = useState(false);
|
||||
const silentSigninHandledRef = useRef(false);
|
||||
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
|
||||
const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
|
||||
useState(false);
|
||||
|
||||
const [resetStep, setResetStep] = useState<
|
||||
"initiate" | "verify" | "newPassword"
|
||||
@@ -258,6 +262,17 @@ export function Auth({
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getOidcSilentLoginDefault()
|
||||
.then((res) => {
|
||||
setOidcSilentLoginDefault(res.enabled);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setOidcSilentLoginDefaultLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showServerConfig) {
|
||||
return;
|
||||
@@ -683,8 +698,19 @@ export function Auth({
|
||||
setLdapLoading(true);
|
||||
try {
|
||||
await ldapLogin(providerId, ldapUsername, ldapPassword, rememberMe);
|
||||
const userInfo = await getUserInfo();
|
||||
onLogin(userInfo);
|
||||
const meRes = await getUserInfo();
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
response?: { data?: { error?: string } };
|
||||
@@ -699,19 +725,35 @@ export function Auth({
|
||||
setLdapLoading(false);
|
||||
}
|
||||
},
|
||||
[ldapUsername, ldapPassword, rememberMe, onLogin, t],
|
||||
[
|
||||
ldapUsername,
|
||||
ldapPassword,
|
||||
rememberMe,
|
||||
onAuthSuccess,
|
||||
setLoggedIn,
|
||||
setIsAdmin,
|
||||
setUsername,
|
||||
setUserId,
|
||||
setDbError,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
|
||||
if (!shouldTriggerSilentSignin(window.location.search)) return;
|
||||
if (!oidcSilentLoginDefaultLoaded) return;
|
||||
|
||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
||||
);
|
||||
const urlTriggered = shouldTriggerSilentSignin(window.location.search);
|
||||
if (!urlTriggered && !oidcSilentLoginDefault) return;
|
||||
|
||||
if (urlTriggered) {
|
||||
const nextSearch = removeSilentSigninFromSearch(window.location.search);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
`${window.location.pathname}${nextSearch}${window.location.hash}`,
|
||||
);
|
||||
}
|
||||
|
||||
silentSigninHandledRef.current = true;
|
||||
const oidcProvider = ssoProviders.find(
|
||||
@@ -728,8 +770,17 @@ export function Auth({
|
||||
return;
|
||||
}
|
||||
|
||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||
}, [handleOIDCLogin, ssoProvidersLoaded, ssoProviders, t]);
|
||||
if (urlTriggered) {
|
||||
toast.info(t("errors.silentSigninOidcUnavailable"));
|
||||
}
|
||||
}, [
|
||||
handleOIDCLogin,
|
||||
ssoProvidersLoaded,
|
||||
ssoProviders,
|
||||
t,
|
||||
oidcSilentLoginDefault,
|
||||
oidcSilentLoginDefaultLoaded,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
@@ -1515,106 +1566,122 @@ export function Auth({
|
||||
className="flex flex-col gap-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) =>
|
||||
setLocalUsername(e.target.value)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">
|
||||
{t("common.password")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "login" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberMe(checked === true)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="rememberMe"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
{!passwordLoginAllowed &&
|
||||
!firstUser &&
|
||||
tab === "login" ? (
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
{t("auth.passwordLoginDisabledDesc")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) =>
|
||||
setLocalUsername(e.target.value)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">
|
||||
{t("common.password")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={(e) =>
|
||||
setPassword(e.target.value)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "login" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="rememberMe"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(checked) =>
|
||||
setRememberMe(checked === true)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="rememberMe"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
{t("auth.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={(e) =>
|
||||
setSignupConfirmPassword(e.target.value)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
>
|
||||
{t("auth.rememberMe")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={(e) =>
|
||||
setSignupConfirmPassword(e.target.value)
|
||||
}
|
||||
disabled={loading || loggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
>
|
||||
{loading
|
||||
? Spinner
|
||||
: tab === "login"
|
||||
? t("common.login")
|
||||
: t("auth.signUp")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || loggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
{loading
|
||||
? Spinner
|
||||
: tab === "login"
|
||||
? t("common.login")
|
||||
: t("auth.signUp")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || loggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{ssoProviders.length > 0 && !isElectron() && (
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="text-xs text-muted-foreground px-1 whitespace-nowrap">
|
||||
{t("auth.orContinueWith")}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
{(passwordLoginAllowed ||
|
||||
firstUser ||
|
||||
tab === "signup") && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 border-t border-border" />
|
||||
<span className="text-xs text-muted-foreground px-1 whitespace-nowrap">
|
||||
{t("auth.orContinueWith")}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-border" />
|
||||
</div>
|
||||
)}
|
||||
{ssoProviders.map((provider) => {
|
||||
if (provider.type === "ldap") {
|
||||
const isExpanded =
|
||||
|
||||
@@ -34,6 +34,8 @@ interface ProxmoxDiscoverDialogProps {
|
||||
preselectedHostId?: number;
|
||||
/** Credential to use for imported hosts */
|
||||
defaultCredentialId?: number | null;
|
||||
/** Auth type to use for imported hosts */
|
||||
defaultAuthType?: string;
|
||||
/** Username from the default credential */
|
||||
defaultUsername?: string;
|
||||
}
|
||||
@@ -45,6 +47,7 @@ export function ProxmoxDiscoverDialog({
|
||||
onHostsChanged,
|
||||
preselectedHostId,
|
||||
defaultCredentialId,
|
||||
defaultAuthType,
|
||||
defaultUsername,
|
||||
}: ProxmoxDiscoverDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -115,6 +118,8 @@ export function ProxmoxDiscoverDialog({
|
||||
try {
|
||||
// Prefer explicitly configured credential, then fall back to the host's own credential
|
||||
const credId = defaultCredentialId ?? discoveredCredentialId;
|
||||
const resolvedAuthType =
|
||||
defaultAuthType ?? (credId != null ? "credential" : "password");
|
||||
|
||||
const toImport = guests
|
||||
.filter((g) => selected.has(g.vmid))
|
||||
@@ -124,13 +129,13 @@ export function ProxmoxDiscoverDialog({
|
||||
port: g.connectionType === "rdp" ? 3389 : 22,
|
||||
username: defaultUsername ?? "root",
|
||||
folder: importFolder,
|
||||
...(credId != null
|
||||
authType: resolvedAuthType,
|
||||
...(resolvedAuthType === "credential" && credId != null
|
||||
? {
|
||||
authType: "credential" as const,
|
||||
credentialId: credId,
|
||||
overrideCredentialUsername: true,
|
||||
}
|
||||
: { authType: "password" as const }),
|
||||
: {}),
|
||||
enableTerminal: g.connectionType !== "rdp",
|
||||
enableFileManager: g.connectionType !== "rdp",
|
||||
enableTunnel: g.connectionType !== "rdp",
|
||||
|
||||
@@ -6,10 +6,12 @@ import { Separator } from "@/components/separator";
|
||||
import {
|
||||
Activity,
|
||||
Database,
|
||||
ExternalLink,
|
||||
GripHorizontal,
|
||||
GripVertical,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link,
|
||||
MessagesSquare,
|
||||
Network,
|
||||
Plus,
|
||||
@@ -37,10 +39,21 @@ import {
|
||||
registerMetricsViewer,
|
||||
sendMetricsHeartbeat,
|
||||
getUserInfo,
|
||||
getServiceLinks,
|
||||
createServiceLink,
|
||||
deleteServiceLink,
|
||||
} from "@/main-axios";
|
||||
import type {
|
||||
RecentActivityItem,
|
||||
SSHHostWithStatus,
|
||||
ServiceLink,
|
||||
} from "@/main-axios";
|
||||
import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
|
||||
function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
return {
|
||||
@@ -66,7 +79,7 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
macAddress: h.macAddress,
|
||||
enableTerminal: h.enableTerminal ?? true,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableFileManager: h.enableFileManager ?? true,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableSsh: h.connectionType === "ssh" || !h.connectionType,
|
||||
enableRdp: h.connectionType === "rdp",
|
||||
@@ -205,35 +218,48 @@ function CountersBarCard({
|
||||
hosts,
|
||||
credentialCount,
|
||||
activeTunnelCount,
|
||||
onOpenSingletonTab,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
credentialCount: number;
|
||||
activeTunnelCount: number;
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="grid grid-cols-3 divide-x divide-border overflow-hidden w-full h-full py-0 gap-0">
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5">
|
||||
<button
|
||||
onClick={() => onOpenSingletonTab("host-manager")}
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<Server className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-base font-bold">{hosts.length}</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("dashboard.totalHosts")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5">
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onOpenSingletonTab("host-manager", "host-manager:show-credentials")
|
||||
}
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-base font-bold">{credentialCount}</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5">
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenSingletonTab("tunnel")}
|
||||
className="flex items-center gap-2.5 px-4 py-2.5 hover:bg-muted transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<Network className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-base font-bold">{activeTunnelCount}</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("dashboardTab.activeTunnels")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -365,16 +391,48 @@ function QuickActionsCard({
|
||||
);
|
||||
}
|
||||
|
||||
function MetricBar({ label, value }: { label: string; value: number }) {
|
||||
const color =
|
||||
value >= 90
|
||||
? "bg-red-500"
|
||||
: value >= 70
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand";
|
||||
const textColor =
|
||||
value >= 90
|
||||
? "text-red-400"
|
||||
: value >= 70
|
||||
? "text-yellow-400"
|
||||
: "text-accent-brand";
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 w-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
<span className={`text-[10px] font-bold ${textColor}`}>
|
||||
{value.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-muted w-full">
|
||||
<div className={`h-full ${color}`} style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HostStatusCard({
|
||||
hosts,
|
||||
hostMetrics,
|
||||
onOpenTab,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
||||
hostMetrics: Map<
|
||||
string,
|
||||
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||
>;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const statusScheme = useStatusColorScheme();
|
||||
const online = hosts.filter((h) => h.online).length;
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||
@@ -399,19 +457,23 @@ function HostStatusCard({
|
||||
const metrics = hostMetrics.get(host.id);
|
||||
const cpu = metrics?.cpu ?? null;
|
||||
const ram = metrics?.ram ?? null;
|
||||
const hasMetrics = cpu !== null || ram !== null;
|
||||
const disk = metrics?.disk ?? null;
|
||||
const hasMetrics = cpu !== null || ram !== null || disk !== null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onOpenTab(host, "host-metrics")}
|
||||
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
|
||||
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer group/row"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${host.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot")}`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold">{host.name}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-semibold">{host.name}</span>
|
||||
<ExternalLink className="size-2.5 text-muted-foreground/0 group-hover/row:text-muted-foreground/60 transition-colors shrink-0" />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{host.ip}
|
||||
</span>
|
||||
@@ -421,40 +483,13 @@ function HostStatusCard({
|
||||
{host.online && hasMetrics ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{cpu !== null && (
|
||||
<div className="flex flex-col gap-0.5 w-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("dashboard.cpu")}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-accent-brand">
|
||||
{cpu.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-muted w-full">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{ width: `${cpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MetricBar label={t("dashboard.cpu")} value={cpu} />
|
||||
)}
|
||||
{ram !== null && (
|
||||
<div className="flex flex-col gap-0.5 w-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("dashboard.ram")}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-accent-brand">
|
||||
{ram.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-muted w-full">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{ width: `${ram}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<MetricBar label={t("dashboard.ram")} value={ram} />
|
||||
)}
|
||||
{disk !== null && (
|
||||
<MetricBar label={t("dashboardTab.disk")} value={disk} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -465,10 +500,13 @@ function HostStatusCard({
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||
—
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${host.online ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-border text-muted-foreground"}`}
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(host.online, statusScheme, "badge")}`}
|
||||
>
|
||||
{host.online
|
||||
? t("dashboardTab.online")
|
||||
@@ -495,6 +533,7 @@ function RecentActivityCard({
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const statusScheme = useStatusColorScheme();
|
||||
const typeIcon: Record<RecentActivityItem["type"], React.ReactNode> = {
|
||||
terminal: <Terminal className="size-2.5" />,
|
||||
file_manager: <Server className="size-2.5" />,
|
||||
@@ -570,7 +609,7 @@ function RecentActivityCard({
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${host?.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.online ?? false, statusScheme, "dot")}`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold truncate max-w-24">
|
||||
@@ -593,6 +632,124 @@ function RecentActivityCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceLinksCard({
|
||||
links,
|
||||
onAdd,
|
||||
onDelete,
|
||||
}: {
|
||||
links: ServiceLink[];
|
||||
onAdd: (label: string, url: string) => Promise<void>;
|
||||
onDelete: (id: number) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [label, setLabel] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const handleAdd = async () => {
|
||||
let valid = true;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
valid = false;
|
||||
}
|
||||
} catch {
|
||||
valid = false;
|
||||
}
|
||||
if (!valid) {
|
||||
setUrlError(true);
|
||||
return;
|
||||
}
|
||||
setUrlError(false);
|
||||
setAdding(true);
|
||||
try {
|
||||
await onAdd(label.trim(), url.trim());
|
||||
setLabel("");
|
||||
setUrl("");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<Link className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("dashboardTab.serviceLinksTitle")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-auto flex-1">
|
||||
{links.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 py-4">
|
||||
{t("dashboardTab.serviceLinksEmpty")}
|
||||
</div>
|
||||
)}
|
||||
{links.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex items-center justify-between px-4 py-2 border-b border-border last:border-0 group/link"
|
||||
>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:text-accent-brand transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{link.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{link.url}
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => onDelete(link.id)}
|
||||
className="ml-2 opacity-0 group-hover/link:opacity-100 transition-opacity size-5 flex items-center justify-center hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-t border-border shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("dashboardTab.serviceLinksLabelPlaceholder")}
|
||||
className="flex-1 min-w-0 text-xs bg-transparent border border-border px-2 py-1 focus:outline-none focus:border-accent-brand/60"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setUrlError(false);
|
||||
}}
|
||||
placeholder={t("dashboardTab.serviceLinksUrlPlaceholder")}
|
||||
className={`flex-[2] min-w-0 text-xs bg-transparent border px-2 py-1 focus:outline-none ${urlError ? "border-destructive" : "border-border focus:border-accent-brand/60"}`}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs bg-accent-brand hover:bg-accent-brand/90 text-white h-6 px-2 shrink-0"
|
||||
onClick={handleAdd}
|
||||
disabled={!label.trim() || !url.trim() || adding}
|
||||
>
|
||||
{t("dashboardTab.serviceLinksAdd")}
|
||||
</Button>
|
||||
</div>
|
||||
{urlError && (
|
||||
<div className="px-4 pb-2 text-[10px] text-destructive shrink-0">
|
||||
{t("dashboardTab.serviceLinksInvalidUrl")}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CardItem ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function CardItem({
|
||||
@@ -617,6 +774,9 @@ function CardItem({
|
||||
activity,
|
||||
onClearActivity,
|
||||
isAdmin,
|
||||
serviceLinks,
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
}: {
|
||||
slot: CardSlot;
|
||||
editMode: boolean;
|
||||
@@ -629,7 +789,10 @@ function CardItem({
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
||||
hostMetrics: Map<
|
||||
string,
|
||||
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||
>;
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
@@ -639,6 +802,9 @@ function CardItem({
|
||||
activity: RecentActivityItem[];
|
||||
onClearActivity: () => void;
|
||||
isAdmin: boolean;
|
||||
serviceLinks: ServiceLink[];
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -704,6 +870,7 @@ function CardItem({
|
||||
hosts={hosts}
|
||||
credentialCount={credentialCount}
|
||||
activeTunnelCount={activeTunnelCount}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "quick_actions" && (
|
||||
@@ -735,6 +902,13 @@ function CardItem({
|
||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "service_links" && (
|
||||
<ServiceLinksCard
|
||||
links={serviceLinks}
|
||||
onAdd={onAddServiceLink}
|
||||
onDelete={onDeleteServiceLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editMode && !isFlex && (
|
||||
<div
|
||||
@@ -831,7 +1005,10 @@ type PanelColumnProps = {
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
||||
hostMetrics: Map<
|
||||
string,
|
||||
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||
>;
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
@@ -842,6 +1019,9 @@ type PanelColumnProps = {
|
||||
onClearActivity: () => void;
|
||||
cardLabels: Record<DashboardCardId, string>;
|
||||
isAdmin: boolean;
|
||||
serviceLinks: ServiceLink[];
|
||||
onAddServiceLink: (label: string, url: string) => Promise<void>;
|
||||
onDeleteServiceLink: (id: number) => Promise<void>;
|
||||
};
|
||||
|
||||
function PanelColumn({
|
||||
@@ -869,6 +1049,9 @@ function PanelColumn({
|
||||
onClearActivity,
|
||||
cardLabels,
|
||||
isAdmin,
|
||||
serviceLinks,
|
||||
onAddServiceLink,
|
||||
onDeleteServiceLink,
|
||||
}: PanelColumnProps) {
|
||||
const { t } = useTranslation();
|
||||
const sorted = [...slots].sort((a, b) => a.order - b.order);
|
||||
@@ -921,6 +1104,9 @@ function PanelColumn({
|
||||
activity={activity}
|
||||
onClearActivity={onClearActivity}
|
||||
isAdmin={isAdmin}
|
||||
serviceLinks={serviceLinks}
|
||||
onAddServiceLink={onAddServiceLink}
|
||||
onDeleteServiceLink={onDeleteServiceLink}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -1028,7 +1214,7 @@ export function DashboardTab({
|
||||
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
|
||||
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
||||
const [hostMetrics, setHostMetrics] = useState<
|
||||
Map<string, { cpu: number | null; ram: number | null }>
|
||||
Map<string, { cpu: number | null; ram: number | null; disk: number | null }>
|
||||
>(new Map());
|
||||
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
|
||||
|
||||
@@ -1066,6 +1252,7 @@ export function DashboardTab({
|
||||
id: host.id,
|
||||
cpu: metrics.cpu?.percent ?? null,
|
||||
ram: metrics.memory?.percent ?? null,
|
||||
disk: metrics.disk?.percent ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -1074,9 +1261,12 @@ export function DashboardTab({
|
||||
);
|
||||
|
||||
viewerSessionsRef.current = newSessions;
|
||||
const map = new Map<string, { cpu: number | null; ram: number | null }>();
|
||||
const map = new Map<
|
||||
string,
|
||||
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||
>();
|
||||
for (const r of results) {
|
||||
if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram });
|
||||
if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram, disk: r.disk });
|
||||
}
|
||||
setHostMetrics(map);
|
||||
}, []);
|
||||
@@ -1169,6 +1359,24 @@ export function DashboardTab({
|
||||
}
|
||||
};
|
||||
|
||||
const [serviceLinks, setServiceLinks] = useState<ServiceLink[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getServiceLinks()
|
||||
.then(setServiceLinks)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleAddServiceLink = async (label: string, url: string) => {
|
||||
const created = await createServiceLink(label, url);
|
||||
setServiceLinks((prev) => [...prev, created]);
|
||||
};
|
||||
|
||||
const handleDeleteServiceLink = async (id: number) => {
|
||||
await deleteServiceLink(id);
|
||||
setServiceLinks((prev) => prev.filter((l) => l.id !== id));
|
||||
};
|
||||
|
||||
const todayLabel = new Date().toLocaleDateString(i18n.language, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
@@ -1192,6 +1400,7 @@ export function DashboardTab({
|
||||
host_status: t("dashboardTab.hostStatus"),
|
||||
recent_activity: t("dashboard.recentActivity"),
|
||||
network_graph: t("dashboard.networkGraph"),
|
||||
service_links: t("dashboard.serviceLinks"),
|
||||
};
|
||||
|
||||
const onColumnDividerMouseDown = useCallback(
|
||||
@@ -1264,7 +1473,9 @@ export function DashboardTab({
|
||||
? 350
|
||||
: id === "host_status" || id === "recent_activity"
|
||||
? null
|
||||
: 150;
|
||||
: id === "service_links"
|
||||
? 200
|
||||
: 150;
|
||||
return [...prev, { id, panel, order: maxOrder, height: defaultHeight }];
|
||||
});
|
||||
};
|
||||
@@ -1299,6 +1510,9 @@ export function DashboardTab({
|
||||
onOpenTab,
|
||||
cardLabels,
|
||||
isAdmin,
|
||||
serviceLinks,
|
||||
onAddServiceLink: handleAddServiceLink,
|
||||
onDeleteServiceLink: handleDeleteServiceLink,
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -1393,6 +1607,7 @@ export function DashboardTab({
|
||||
hosts={hosts}
|
||||
credentialCount={credentialCount}
|
||||
activeTunnelCount={activeTunnelCount}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "quick_actions" && (
|
||||
@@ -1424,6 +1639,13 @@ export function DashboardTab({
|
||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "service_links" && (
|
||||
<ServiceLinksCard
|
||||
links={serviceLinks}
|
||||
onAdd={handleAddServiceLink}
|
||||
onDelete={handleDeleteServiceLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -128,11 +128,17 @@ function buildNodeSvg(
|
||||
): string {
|
||||
const isOnline = status === "online";
|
||||
const isOffline = status === "offline";
|
||||
const statusColor = isOnline
|
||||
? "rgb(16,185,129)"
|
||||
: isOffline
|
||||
? "rgb(239,68,68)"
|
||||
: "rgb(100,116,139)";
|
||||
const useRealColors = localStorage.getItem("statusColorScheme") === "status";
|
||||
let statusColor: string;
|
||||
if (isOnline) {
|
||||
statusColor = useRealColors
|
||||
? "rgb(16,185,129)"
|
||||
: resolveCssVar("--accent-brand", "rgb(16,185,129)");
|
||||
} else if (isOffline) {
|
||||
statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)";
|
||||
} else {
|
||||
statusColor = "rgb(100,116,139)";
|
||||
}
|
||||
|
||||
const bg = resolveCssVar("--card", "#1e1e20");
|
||||
const border = resolveCssVar("--border", "#2a2a2c");
|
||||
|
||||
@@ -204,7 +204,7 @@ export function LogViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-[#111210] border border-border p-3 overflow-auto font-mono text-xs leading-relaxed scrollbar-thin min-h-0">
|
||||
<div className="flex-1 bg-muted border border-border p-3 overflow-auto font-mono text-xs leading-relaxed scrollbar-thin min-h-0">
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((line, i) => {
|
||||
const tsEnd = line.indexOf(" ", 1);
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
|
||||
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
|
||||
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
|
||||
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
|
||||
import { TerminalWindow } from "./components/TerminalWindow.tsx";
|
||||
@@ -78,7 +79,12 @@ import type {
|
||||
} from "./file-manager-types.ts";
|
||||
import { formatFileSize } from "./file-manager-utils.ts";
|
||||
|
||||
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
function FileManagerContent({
|
||||
initialHost,
|
||||
initialFilePath,
|
||||
initialPath,
|
||||
onClose,
|
||||
}: FileManagerProps) {
|
||||
const { openWindow } = useWindowManager();
|
||||
const { t } = useTranslation();
|
||||
const formatTransferMetrics = useMemo(
|
||||
@@ -94,10 +100,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
||||
const [currentPath, setCurrentPath] = useState(
|
||||
initialHost?.defaultPath || "/",
|
||||
initialPath || initialHost?.defaultPath || "/",
|
||||
);
|
||||
const [navHistory, setNavHistory] = useState<string[]>([
|
||||
initialHost?.defaultPath || "/",
|
||||
initialPath || initialHost?.defaultPath || "/",
|
||||
]);
|
||||
const [navIndex, setNavIndex] = useState(0);
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
@@ -133,6 +139,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const [authDialogReason, setAuthDialogReason] = useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
|
||||
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
||||
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
||||
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
|
||||
@@ -267,6 +274,60 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
};
|
||||
}, [sshSessionId, startKeepalive, stopKeepalive]);
|
||||
|
||||
const initialFileOpenedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!sshSessionId || !initialFilePath || initialFileOpenedRef.current)
|
||||
return;
|
||||
initialFileOpenedRef.current = true;
|
||||
|
||||
const fileName = initialFilePath.split("/").pop() || initialFilePath;
|
||||
const fileDir =
|
||||
initialFilePath.lastIndexOf("/") > 0
|
||||
? initialFilePath.substring(0, initialFilePath.lastIndexOf("/"))
|
||||
: "/";
|
||||
|
||||
setCurrentPath(fileDir);
|
||||
|
||||
const file: FileItem = {
|
||||
name: fileName,
|
||||
path: initialFilePath,
|
||||
type: "file",
|
||||
};
|
||||
|
||||
const windowCount = Date.now() % 10;
|
||||
const offsetX = Math.min(
|
||||
120 + windowCount * 30,
|
||||
Math.max(0, window.innerWidth - 820),
|
||||
);
|
||||
const offsetY = Math.min(
|
||||
120 + windowCount * 30,
|
||||
Math.max(0, window.innerHeight - 620),
|
||||
);
|
||||
|
||||
const createWindowComponent = (windowId: string) => (
|
||||
<FileWindow
|
||||
windowId={windowId}
|
||||
file={file}
|
||||
sshSessionId={sshSessionId}
|
||||
sshHost={currentHost}
|
||||
initialX={offsetX}
|
||||
initialY={offsetY}
|
||||
onFileNotFound={handleFileNotFound}
|
||||
/>
|
||||
);
|
||||
|
||||
openWindow({
|
||||
title: fileName,
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
width: 800,
|
||||
height: 600,
|
||||
isMaximized: false,
|
||||
isMinimized: false,
|
||||
component: createWindowComponent,
|
||||
});
|
||||
}, [sshSessionId, initialFilePath]);
|
||||
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
const lastPathChangeRef = useRef<string>("");
|
||||
const pathChangeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -416,6 +477,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "passphrase_required") {
|
||||
setShowPassphraseDialog(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
try {
|
||||
@@ -839,24 +906,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
"/"
|
||||
: currentPath;
|
||||
|
||||
const fileContent = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result.split(",")[1] || "");
|
||||
} else {
|
||||
reject(new Error("Failed to read file"));
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
await uploadSSHFile(
|
||||
sshSessionId,
|
||||
uploadPath,
|
||||
file.name,
|
||||
fileContent,
|
||||
file,
|
||||
currentHost?.id,
|
||||
);
|
||||
}
|
||||
@@ -896,26 +950,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const fileContent = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error);
|
||||
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
const base64 = reader.result.split(",")[1] || "";
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to read file"));
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
await uploadSSHFile(
|
||||
sshSessionId,
|
||||
currentPath,
|
||||
file.name,
|
||||
fileContent,
|
||||
file,
|
||||
currentHost?.id,
|
||||
undefined,
|
||||
);
|
||||
@@ -1357,6 +1396,23 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
});
|
||||
}
|
||||
|
||||
function handleCopyFolderLink(path: string) {
|
||||
if (!currentHost?.id) return;
|
||||
const params = new URLSearchParams({
|
||||
view: "file-manager",
|
||||
hostId: String(currentHost.id),
|
||||
path,
|
||||
});
|
||||
const url = `${window.location.origin}?${params.toString()}`;
|
||||
copyToClipboard(url).then((ok) => {
|
||||
if (ok) {
|
||||
toast.success(t("fileManager.folderLinkCopied"));
|
||||
} else {
|
||||
toast.error(t("fileManager.failedToCopyFolderLink"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handlePasteFiles() {
|
||||
if (!clipboard || !sshSessionId) return;
|
||||
|
||||
@@ -2120,6 +2176,85 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
async function handlePassphraseSubmit(passphrase: string) {
|
||||
if (!currentHost) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setShowPassphraseDialog(false);
|
||||
|
||||
const sessionId = currentHost.id.toString();
|
||||
|
||||
const result = await connectSSH(sessionId, {
|
||||
hostId: currentHost.id,
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: passphrase,
|
||||
authType: "key",
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
jumpHosts: currentHost.jumpHosts,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
socks5Username: currentHost.socks5Username,
|
||||
socks5Password: currentHost.socks5Password,
|
||||
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.status === "passphrase_required") {
|
||||
setShowPassphraseDialog(true);
|
||||
setIsLoading(false);
|
||||
toast.error(t("fileManager.incorrectPassphrase"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sessionId);
|
||||
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setAuthDialogReason(result.reason || "auth_failed");
|
||||
setShowAuthDialog(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sessionId, currentPath);
|
||||
const files = Array.isArray(response)
|
||||
? response
|
||||
: response?.files || [];
|
||||
setFiles(files);
|
||||
clearSelection();
|
||||
initialLoadDoneRef.current = true;
|
||||
toast.success(t("fileManager.connectedSuccessfully"));
|
||||
logFileManagerActivity();
|
||||
} catch (dirError: unknown) {
|
||||
console.error("Failed to load initial directory:", dirError);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("SSH connection with passphrase failed:", error);
|
||||
setShowPassphraseDialog(true);
|
||||
toast.error(t("fileManager.incorrectPassphrase"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePassphraseCancel() {
|
||||
setShowPassphraseDialog(false);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function generateUniqueName(
|
||||
baseName: string,
|
||||
type: "file" | "directory",
|
||||
@@ -2749,6 +2884,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onExtractArchive={handleExtractArchive}
|
||||
onCompress={handleOpenCompressDialog}
|
||||
onCopyPath={handleCopyPath}
|
||||
onCopyFolderLink={handleCopyFolderLink}
|
||||
onTransferToHost={handleOpenTransferDialog}
|
||||
/>
|
||||
</div>
|
||||
@@ -2768,6 +2904,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentHost && (
|
||||
<PassphraseDialog
|
||||
isOpen={showPassphraseDialog}
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
onCancel={handlePassphraseCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
name: currentHost.name,
|
||||
}}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FileManagerDialogs
|
||||
compressDialogFiles={compressDialogFiles}
|
||||
setCompressDialogFiles={setCompressDialogFiles}
|
||||
@@ -2805,10 +2956,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function FileManagerInner({ initialHost, onClose }: FileManagerProps) {
|
||||
function FileManagerInner({
|
||||
initialHost,
|
||||
initialFilePath,
|
||||
initialPath,
|
||||
onClose,
|
||||
}: FileManagerProps) {
|
||||
return (
|
||||
<WindowManager>
|
||||
<FileManagerContent initialHost={initialHost} onClose={onClose} />
|
||||
<FileManagerContent
|
||||
initialHost={initialHost}
|
||||
initialFilePath={initialFilePath}
|
||||
initialPath={initialPath}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</WindowManager>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,13 @@ import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
interface FileManagerAppProps {
|
||||
hostId?: string;
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
||||
const FileManagerApp: React.FC<FileManagerAppProps> = ({
|
||||
hostId,
|
||||
initialPath,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
@@ -39,6 +43,7 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
||||
<FileManager
|
||||
embedded={true}
|
||||
initialHost={hostConfig}
|
||||
initialPath={initialPath}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Bookmark,
|
||||
FileArchive,
|
||||
ArrowRightLeft,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
|
||||
@@ -67,6 +68,7 @@ interface ContextMenuProps {
|
||||
onExtractArchive?: (file: FileItem) => void;
|
||||
onCompress?: (files: FileItem[]) => void;
|
||||
onCopyPath?: (files: FileItem[]) => void;
|
||||
onCopyFolderLink?: (path: string) => void;
|
||||
onTransferToHost?: (files: FileItem[], move: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -110,6 +112,7 @@ export function FileManagerContextMenu({
|
||||
onExtractArchive,
|
||||
onCompress,
|
||||
onCopyPath,
|
||||
onCopyFolderLink,
|
||||
onTransferToHost,
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -350,12 +353,22 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "directory" && onCopyFolderLink) {
|
||||
menuItems.push({
|
||||
icon: <Link className="size-3.5" />,
|
||||
label: t("fileManager.copyFolderLink"),
|
||||
action: () => onCopyFolderLink(files[0].path),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(hasFiles && (onPreview || onDragToDesktop)) ||
|
||||
(isSingleFile &&
|
||||
files[0].type === "file" &&
|
||||
(onPinFile || onUnpinFile)) ||
|
||||
(isSingleFile && files[0].type === "directory" && onAddShortcut)
|
||||
(isSingleFile &&
|
||||
files[0].type === "directory" &&
|
||||
(onAddShortcut || onCopyFolderLink))
|
||||
) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
@@ -491,6 +504,15 @@ export function FileManagerContextMenu({
|
||||
shortcut: "Ctrl+V",
|
||||
});
|
||||
}
|
||||
|
||||
if (onCopyFolderLink && currentPath) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
menuItems.push({
|
||||
icon: <Link className="size-3.5" />,
|
||||
label: t("fileManager.copyCurrentFolderLink"),
|
||||
action: () => onCopyFolderLink(currentPath),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredMenuItems = menuItems.filter((item, index) => {
|
||||
|
||||
@@ -173,9 +173,11 @@ export function FileManagerSidebar({
|
||||
try {
|
||||
const response = await listSSHFiles(sshSessionId, "/");
|
||||
const rootFiles = (response.files || []) as DirectoryItemData[];
|
||||
const rootFolders = rootFiles.filter(
|
||||
(item: DirectoryItemData) => item.type === "directory",
|
||||
);
|
||||
const rootFolders = rootFiles
|
||||
.filter((item: DirectoryItemData) => item.type === "directory")
|
||||
.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
|
||||
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.name}`,
|
||||
@@ -232,9 +234,11 @@ export function FileManagerSidebar({
|
||||
try {
|
||||
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
||||
const subFiles = (subResponse.files || []) as DirectoryItemData[];
|
||||
const subFolders = subFiles.filter(
|
||||
(item: DirectoryItemData) => item.type === "directory",
|
||||
);
|
||||
const subFolders = subFiles
|
||||
.filter((item: DirectoryItemData) => item.type === "directory")
|
||||
.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
|
||||
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.path.replace(/\//g, "-")}`,
|
||||
|
||||
@@ -23,6 +23,7 @@ interface CodeEditorProps {
|
||||
onChange: (value: string) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
function getLanguageExtension(filename: string) {
|
||||
@@ -73,7 +74,7 @@ function getLanguageExtension(filename: string) {
|
||||
|
||||
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
||||
function CodeEditor(
|
||||
{ fileName, value, placeholder, onChange, onFocus, onBlur },
|
||||
{ fileName, value, placeholder, onChange, onFocus, onBlur, fontSize = 14 },
|
||||
ref,
|
||||
) {
|
||||
const editorRef = useRef<{ view?: EditorView } | null>(null);
|
||||
@@ -105,6 +106,7 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: `${fontSize}px`,
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
@@ -116,7 +118,7 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [fileName]);
|
||||
}, [fileName, fontSize]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
RotateCcw,
|
||||
Keyboard,
|
||||
Search,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SiJavascript,
|
||||
@@ -85,6 +87,7 @@ interface FileViewerProps {
|
||||
savedContent?: string;
|
||||
isLoading?: boolean;
|
||||
isEditable?: boolean;
|
||||
resetKey?: number;
|
||||
onContentChange?: (content: string) => void;
|
||||
onSave?: (content: string) => void;
|
||||
onRevert?: () => void;
|
||||
@@ -265,6 +268,7 @@ export function FileViewer({
|
||||
savedContent = "",
|
||||
isLoading = false,
|
||||
isEditable = false,
|
||||
resetKey,
|
||||
onContentChange,
|
||||
onSave,
|
||||
onRevert,
|
||||
@@ -280,8 +284,31 @@ export function FileViewer({
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
|
||||
const [editorFocused, setEditorFocused] = useState(false);
|
||||
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
||||
const [editorFontSize, setEditorFontSize] = useState<number>(() => {
|
||||
const stored = localStorage.getItem("fileManagerEditorFontSize");
|
||||
return stored ? parseInt(stored, 10) : 14;
|
||||
});
|
||||
const editorRef = useRef<CodeEditorHandle | null>(null);
|
||||
|
||||
const MIN_FONT_SIZE = 8;
|
||||
const MAX_FONT_SIZE = 32;
|
||||
|
||||
const decreaseFontSize = () => {
|
||||
setEditorFontSize((prev) => {
|
||||
const next = Math.max(MIN_FONT_SIZE, prev - 1);
|
||||
localStorage.setItem("fileManagerEditorFontSize", String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const increaseFontSize = () => {
|
||||
setEditorFontSize((prev) => {
|
||||
const next = Math.min(MAX_FONT_SIZE, prev + 1);
|
||||
localStorage.setItem("fileManagerEditorFontSize", String(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const fileTypeInfo = getFileType(file.name);
|
||||
|
||||
const WARNING_SIZE = 50 * 1024 * 1024;
|
||||
@@ -301,6 +328,9 @@ export function FileViewer({
|
||||
if (savedContent) {
|
||||
setOriginalContent(savedContent);
|
||||
}
|
||||
}, [file.name, file.path, resetKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setHasChanges(content !== savedContent);
|
||||
|
||||
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
|
||||
@@ -308,14 +338,7 @@ export function FileViewer({
|
||||
} else {
|
||||
setShowLargeFileWarning(false);
|
||||
}
|
||||
}, [
|
||||
content,
|
||||
savedContent,
|
||||
fileTypeInfo.type,
|
||||
isLargeFile,
|
||||
forceShowAsText,
|
||||
file.name,
|
||||
]);
|
||||
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
|
||||
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setEditedContent(newContent);
|
||||
@@ -417,6 +440,33 @@ export function FileViewer({
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isEditable && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={decreaseFontSize}
|
||||
disabled={editorFontSize <= MIN_FONT_SIZE}
|
||||
title={t("fileManager.decreaseFontSize")}
|
||||
className="w-7 h-7 p-0"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground w-8 text-center select-none">
|
||||
{editorFontSize}px
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={increaseFontSize}
|
||||
disabled={editorFontSize >= MAX_FONT_SIZE}
|
||||
title={t("fileManager.increaseFontSize")}
|
||||
className="w-7 h-7 p-0"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isEditable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -684,6 +734,7 @@ export function FileViewer({
|
||||
onFocus={() => setEditorFocused(true)}
|
||||
onBlur={() => setEditorFocused(false)}
|
||||
placeholder={t("fileManager.startTyping")}
|
||||
fontSize={editorFontSize}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
|
||||
@@ -56,7 +56,8 @@ function isDisplayableText(str: string): boolean {
|
||||
(code >= 32 && code <= 126) ||
|
||||
code === 9 ||
|
||||
code === 10 ||
|
||||
code === 13
|
||||
code === 13 ||
|
||||
code >= 128
|
||||
) {
|
||||
printable++;
|
||||
}
|
||||
@@ -82,6 +83,7 @@ export function FileWindow({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [pendingContent, setPendingContent] = useState<string>("");
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [mediaDimensions, setMediaDimensions] = useState<
|
||||
{ width: number; height: number } | undefined
|
||||
>();
|
||||
@@ -141,6 +143,7 @@ export function FileWindow({
|
||||
|
||||
setContent(fileContent);
|
||||
setPendingContent(fileContent);
|
||||
setResetKey((k) => k + 1);
|
||||
|
||||
if (!file.size) {
|
||||
const contentSize = new Blob([fileContent]).size;
|
||||
@@ -268,6 +271,7 @@ export function FileWindow({
|
||||
const fileContent = response.content || "";
|
||||
setContent(fileContent);
|
||||
setPendingContent("");
|
||||
setResetKey((k) => k + 1);
|
||||
|
||||
if (!file.size) {
|
||||
const contentSize = new Blob([fileContent]).size;
|
||||
@@ -444,6 +448,7 @@ export function FileWindow({
|
||||
content={pendingContent || content}
|
||||
savedContent={content}
|
||||
isLoading={isLoading}
|
||||
resetKey={resetKey}
|
||||
onRevert={handleRevert}
|
||||
isEditable={isEditable}
|
||||
onContentChange={handleContentChange}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { LogEntry } from "@/types/connection-log.ts";
|
||||
|
||||
export interface FileManagerProps {
|
||||
initialHost?: SSHHost | null;
|
||||
initialFilePath?: string;
|
||||
initialPath?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getGuacamoleTokenFromHost,
|
||||
getGuacdStatus,
|
||||
getSSHHosts,
|
||||
logActivity,
|
||||
} from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
@@ -76,6 +77,7 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
|
||||
<GuacamoleAppInner
|
||||
hostId={parseInt(hostId, 10)}
|
||||
hostConfig={hostConfig}
|
||||
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
|
||||
tabId={tabId}
|
||||
protocol={protocol}
|
||||
/>
|
||||
@@ -85,6 +87,7 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: Pick<SSHHost, "connectionType">;
|
||||
hostName: string;
|
||||
tabId?: string;
|
||||
protocol?: "rdp" | "vnc" | "telnet";
|
||||
}
|
||||
@@ -92,6 +95,7 @@ interface GuacamoleAppInnerProps {
|
||||
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
hostId,
|
||||
hostConfig,
|
||||
hostName,
|
||||
tabId,
|
||||
protocol,
|
||||
}) => {
|
||||
@@ -114,7 +118,13 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
return getGuacamoleTokenFromHost(hostId, protocol);
|
||||
})
|
||||
.then((result) => {
|
||||
if (result) setToken(result.token);
|
||||
if (result) {
|
||||
setToken(result.token);
|
||||
const resolvedProtocol = (protocol ??
|
||||
hostConfig.connectionType ??
|
||||
"rdp") as "rdp" | "vnc" | "telnet";
|
||||
logActivity(resolvedProtocol, hostId, hostName).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
}, [hostId, protocol, retryCount, t]);
|
||||
|
||||
@@ -541,6 +541,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const h = Math.round(rect.height);
|
||||
if (w > 0 && h > 0) {
|
||||
clientRef.current.sendSize(w, h);
|
||||
rescaleDisplay(true);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
X,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/sheet";
|
||||
import type { TerminalHandle } from "./terminal-types";
|
||||
|
||||
interface MobileTerminalKeyboardProps {
|
||||
terminalRef: React.RefObject<TerminalHandle | null>;
|
||||
}
|
||||
|
||||
const CTRL_MAP: Record<string, string> = {
|
||||
a: "\x01",
|
||||
c: "\x03",
|
||||
d: "\x04",
|
||||
k: "\x0B",
|
||||
l: "\x0C",
|
||||
r: "\x12",
|
||||
u: "\x15",
|
||||
w: "\x17",
|
||||
z: "\x1A",
|
||||
};
|
||||
|
||||
const DEFAULT_QUICK_KEYS = [
|
||||
"/",
|
||||
"|",
|
||||
"~",
|
||||
"-",
|
||||
"_",
|
||||
"#",
|
||||
"\\",
|
||||
'"',
|
||||
"'",
|
||||
";",
|
||||
":",
|
||||
"!",
|
||||
"&",
|
||||
];
|
||||
|
||||
const LS_KEY = "termix:mobileQuickKeys";
|
||||
|
||||
function loadQuickKeys(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string"))
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return DEFAULT_QUICK_KEYS;
|
||||
}
|
||||
|
||||
function saveQuickKeys(keys: string[]) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(keys));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Shared button styles
|
||||
const KEY_BASE =
|
||||
"flex items-center justify-center rounded border transition-colors select-none touch-none active:scale-95 shrink-0";
|
||||
const KEY_NORMAL = "border-border bg-muted/50 text-foreground hover:bg-muted";
|
||||
const KEY_ACTIVE =
|
||||
"border-accent-brand bg-accent-brand/20 text-accent-brand shadow-[0_0_0_1px_color-mix(in_oklab,var(--accent-brand)_30%,transparent)]";
|
||||
const KEY_MD = "h-9 px-3 min-w-[2.75rem] text-xs font-medium";
|
||||
const KEY_SM = "h-9 w-9";
|
||||
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
|
||||
|
||||
// --- QuickKeysSheet ---
|
||||
|
||||
interface QuickKeysSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
quickKeys: string[];
|
||||
onUpdateKeys: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
function QuickKeysSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
quickKeys,
|
||||
onUpdateKeys,
|
||||
}: QuickKeysSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [newSymbol, setNewSymbol] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setNewSymbol("");
|
||||
}, [open]);
|
||||
|
||||
function addKey() {
|
||||
const sym = newSymbol.trim();
|
||||
if (!sym || quickKeys.includes(sym) || sym.length > 8) {
|
||||
setNewSymbol("");
|
||||
return;
|
||||
}
|
||||
onUpdateKeys([...quickKeys, sym]);
|
||||
setNewSymbol("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function removeKey(index: number) {
|
||||
onUpdateKeys(quickKeys.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
onUpdateKeys(DEFAULT_QUICK_KEYS);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="max-h-[70vh] flex flex-col border-border"
|
||||
>
|
||||
<SheetHeader className="flex-row items-start justify-between pr-8">
|
||||
<div>
|
||||
<SheetTitle>{t("mobileKeyboard.quickKeysTitle")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("mobileKeyboard.quickKeysDesc")}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={resetDefaults}
|
||||
>
|
||||
<RotateCcw className="size-3 mr-1" />
|
||||
{t("mobileKeyboard.resetDefaults")}
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickKeys.map((sym, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1 h-8 pl-3 pr-1 rounded border border-border bg-muted/50 text-xs font-medium text-foreground"
|
||||
>
|
||||
<span className="font-mono">{sym}</span>
|
||||
<button
|
||||
className="size-5 flex items-center justify-center rounded hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||||
onClick={() => removeKey(i)}
|
||||
title={t("mobileKeyboard.removeQuickKey")}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 px-4 py-2 border-t border-border">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={newSymbol}
|
||||
onChange={(e) => setNewSymbol(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") addKey();
|
||||
}}
|
||||
maxLength={8}
|
||||
placeholder={t("mobileKeyboard.quickKeyPlaceholder")}
|
||||
className="h-9 text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 shrink-0"
|
||||
onClick={addKey}
|
||||
disabled={!newSymbol.trim() || quickKeys.includes(newSymbol.trim())}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("mobileKeyboard.addQuickKey")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="pt-0">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t("mobileKeyboard.done")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// --- CtrlPanel ---
|
||||
|
||||
interface CtrlPanelProps {
|
||||
onSend: (letter: string) => void;
|
||||
}
|
||||
|
||||
function CtrlPanel({ onSend }: CtrlPanelProps) {
|
||||
const CTRL_KEYS = ["c", "d", "l", "u", "z", "a", "r", "w", "k"];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-muted/30 border-t border-border overflow-x-auto">
|
||||
{CTRL_KEYS.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSend(k);
|
||||
}}
|
||||
title={`Ctrl+${k.toUpperCase()}`}
|
||||
>
|
||||
^{k.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- MobileTerminalKeyboard ---
|
||||
|
||||
export function MobileTerminalKeyboard({
|
||||
terminalRef,
|
||||
}: MobileTerminalKeyboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [ctrlActive, setCtrlActive] = useState(false);
|
||||
const [shiftActive, setShiftActive] = useState(false);
|
||||
const [quickKeys, setQuickKeys] = useState<string[]>(loadQuickKeys);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
function send(seq: string) {
|
||||
terminalRef.current?.sendInput?.(seq);
|
||||
}
|
||||
|
||||
function toggleCtrl() {
|
||||
setCtrlActive((v) => !v);
|
||||
setShiftActive(false);
|
||||
}
|
||||
|
||||
function toggleShift() {
|
||||
setShiftActive((v) => !v);
|
||||
setCtrlActive(false);
|
||||
}
|
||||
|
||||
function sendArrow(normalSeq: string, appSeq: string, shiftSeq: string) {
|
||||
if (shiftActive) {
|
||||
send(shiftSeq);
|
||||
setShiftActive(false);
|
||||
return;
|
||||
}
|
||||
const appMode =
|
||||
terminalRef.current?.getApplicationCursorKeysMode?.() ?? false;
|
||||
send(appMode ? appSeq : normalSeq);
|
||||
}
|
||||
|
||||
function handleTab() {
|
||||
send(shiftActive ? "\x1b[Z" : "\t");
|
||||
setShiftActive(false);
|
||||
}
|
||||
|
||||
function handleCtrlKey(letter: string) {
|
||||
const seq = CTRL_MAP[letter];
|
||||
if (seq) send(seq);
|
||||
setCtrlActive(false);
|
||||
}
|
||||
|
||||
function updateQuickKeys(next: string[]) {
|
||||
setQuickKeys(next);
|
||||
saveQuickKeys(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="md:hidden flex flex-col bg-sidebar border-t border-border shrink-0">
|
||||
{/* Row 1 — special keys */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto">
|
||||
{/* ESC */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b");
|
||||
}}
|
||||
title={t("mobileKeyboard.esc")}
|
||||
>
|
||||
{t("mobileKeyboard.esc")}
|
||||
</button>
|
||||
|
||||
{/* Tab / back-tab */}
|
||||
<button
|
||||
className={cn(
|
||||
KEY_BASE,
|
||||
KEY_NORMAL,
|
||||
KEY_MD,
|
||||
shiftActive && "ring-1 ring-accent-brand/50",
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleTab();
|
||||
}}
|
||||
title={shiftActive ? "Shift+Tab" : t("mobileKeyboard.tab")}
|
||||
>
|
||||
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Ctrl */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_MD, ctrlActive ? KEY_ACTIVE : KEY_NORMAL)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
toggleCtrl();
|
||||
}}
|
||||
title={t("mobileKeyboard.ctrl")}
|
||||
>
|
||||
{t("mobileKeyboard.ctrl")}
|
||||
</button>
|
||||
|
||||
{/* Shift */}
|
||||
<button
|
||||
className={cn(
|
||||
KEY_BASE,
|
||||
KEY_MD,
|
||||
shiftActive ? KEY_ACTIVE : KEY_NORMAL,
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
toggleShift();
|
||||
}}
|
||||
title={t("mobileKeyboard.shift")}
|
||||
>
|
||||
{t("mobileKeyboard.shift")}
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Arrow keys */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[A", "\x1bOA", "\x1b[1;2A");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowUp")}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[B", "\x1bOB", "\x1b[1;2B");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowDown")}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[D", "\x1bOD", "\x1b[1;2D");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowLeft")}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[C", "\x1bOC", "\x1b[1;2C");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowRight")}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Home / End */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[H");
|
||||
}}
|
||||
title={t("mobileKeyboard.home")}
|
||||
>
|
||||
{t("mobileKeyboard.home")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[F");
|
||||
}}
|
||||
title={t("mobileKeyboard.end")}
|
||||
>
|
||||
{t("mobileKeyboard.end")}
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* PgUp / PgDn / Del */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[5~");
|
||||
}}
|
||||
title={t("mobileKeyboard.pageUp")}
|
||||
>
|
||||
{t("mobileKeyboard.pageUp")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[6~");
|
||||
}}
|
||||
title={t("mobileKeyboard.pageDown")}
|
||||
>
|
||||
{t("mobileKeyboard.pageDown")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[3~");
|
||||
}}
|
||||
title={t("mobileKeyboard.delete")}
|
||||
>
|
||||
{t("mobileKeyboard.delete")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ctrl combos panel */}
|
||||
{ctrlActive && <CtrlPanel onSend={handleCtrlKey} />}
|
||||
|
||||
{/* Row 2 — quick keys */}
|
||||
<div className="flex items-center gap-1 px-2 pb-1.5 overflow-x-auto">
|
||||
{quickKeys.map((sym, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send(sym);
|
||||
}}
|
||||
title={sym}
|
||||
>
|
||||
{sym}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="ml-auto shrink-0">
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
title={t("mobileKeyboard.editQuickKeys")}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickKeysSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
quickKeys={quickKeys}
|
||||
onUpdateKeys={updateQuickKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@/lib/terminal-themes.ts";
|
||||
import "./terminal-global-styles.ts";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
|
||||
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
|
||||
import { useCommandHistory } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
@@ -75,6 +76,7 @@ interface SSHTerminalProps {
|
||||
/** Attach to this tmux session right after connecting (tmux monitor). */
|
||||
tmuxAttachSession?: string;
|
||||
onOpenFileManager?: (path?: string) => void;
|
||||
onOpenFileInEditor?: (filePath: string) => void;
|
||||
previewTheme?: string | null;
|
||||
}
|
||||
|
||||
@@ -85,10 +87,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
isVisible,
|
||||
splitScreen = false,
|
||||
onClose,
|
||||
onTitleChange,
|
||||
initialPath,
|
||||
executeCommand,
|
||||
tmuxAttachSession,
|
||||
onOpenFileManager,
|
||||
onOpenFileInEditor,
|
||||
previewTheme,
|
||||
},
|
||||
ref,
|
||||
@@ -114,7 +118,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
|
||||
const backgroundColor = themeColors.background;
|
||||
const backgroundImage = config.backgroundImage || "";
|
||||
const backgroundImageOpacity = config.backgroundImageOpacity ?? 0.15;
|
||||
const backgroundColor = backgroundImage
|
||||
? "transparent"
|
||||
: themeColors.background;
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -176,6 +184,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const pendingRestoredSessionIdRef = useRef<string | null>(
|
||||
hostConfig.restoredSessionId ?? null,
|
||||
);
|
||||
const [linkClickDialog, setLinkClickDialog] = useState<{
|
||||
url: string;
|
||||
} | null>(null);
|
||||
|
||||
const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{
|
||||
sessions: Array<{
|
||||
name: string;
|
||||
@@ -655,6 +667,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
isFittingRef.current = false;
|
||||
}
|
||||
},
|
||||
focus: () => terminal?.focus(),
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
@@ -673,6 +686,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
},
|
||||
refresh: () => hardRefresh(),
|
||||
getApplicationCursorKeysMode: () =>
|
||||
terminal?.modes?.applicationCursorKeysMode ?? false,
|
||||
openFileManager: () => {
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "get_cwd" }));
|
||||
@@ -941,6 +956,24 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
}
|
||||
terminal.onData((data) => {
|
||||
if (data === "\r" || data === "\n") {
|
||||
const currentCmd = getCurrentCommand().trim();
|
||||
const termixMatch = currentCmd.match(/^termix\s+(.+)$/);
|
||||
if (termixMatch && onOpenFileInEditor) {
|
||||
const filePath = termixMatch[1].trim();
|
||||
trackInput(data);
|
||||
terminal.write("\r\n");
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "open_file_in_editor",
|
||||
path: filePath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
trackInput(data);
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
@@ -970,6 +1003,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
if (msg.type === "data") {
|
||||
if (typeof msg.data === "string") {
|
||||
if (showAutocompleteRef.current) {
|
||||
showAutocompleteRef.current = false;
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteSuggestions([]);
|
||||
currentAutocompleteCommand.current = "";
|
||||
}
|
||||
|
||||
const syntaxHighlightingEnabled =
|
||||
hostConfig.terminalConfig?.syntaxHighlighting !== false;
|
||||
|
||||
@@ -982,7 +1022,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
terminal.write(outputData);
|
||||
const sudoPasswordPattern =
|
||||
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required|password for [^\n]*:\s*$|Password:\s*$)/i;
|
||||
/(?:\[sudo\][^\n\r]*:\s*$|sudo:[^\n\r]*password[^\n\r]*required|password for [^\n\r]*:\s*$|Password:\s*$)/im;
|
||||
// Strip ANSI escape codes before testing — newer sudo versions (Ubuntu 26.04+)
|
||||
// emit colored prompts with embedded escape sequences that break the regex.
|
||||
const strippedData = msg.data.replace(
|
||||
/\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g,
|
||||
"",
|
||||
);
|
||||
const hasSudoPw =
|
||||
hostConfig.terminalConfig?.sudoPassword ||
|
||||
hostConfig.password ||
|
||||
@@ -990,7 +1036,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
hostConfig.hasPassword;
|
||||
if (
|
||||
config.sudoPasswordAutoFill &&
|
||||
sudoPasswordPattern.test(msg.data) &&
|
||||
sudoPasswordPattern.test(strippedData) &&
|
||||
hasSudoPw &&
|
||||
!sudoPromptShownRef.current
|
||||
) {
|
||||
@@ -1386,6 +1432,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
} else if (msg.type === "cwd") {
|
||||
onOpenFileManager?.(msg.path as string);
|
||||
} else if (msg.type === "open_file_in_editor") {
|
||||
onOpenFileInEditor?.(msg.path as string);
|
||||
} else if (msg.type === "passphrase_required") {
|
||||
setShowPassphraseDialog(true);
|
||||
setIsConnecting(false);
|
||||
@@ -1792,7 +1840,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
| "both";
|
||||
|
||||
terminal.options.theme = {
|
||||
background: themeColors.background,
|
||||
background: config.backgroundImage
|
||||
? "transparent"
|
||||
: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
cursor: themeColors.cursor,
|
||||
cursorAccent: themeColors.cursorAccent,
|
||||
@@ -1850,7 +1900,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
fontFamily,
|
||||
allowTransparency: true, // MUST be set before open()
|
||||
convertEol: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: config.rightClickSelectsWord,
|
||||
fastScrollSensitivity: config.fastScrollSensitivity,
|
||||
@@ -1860,7 +1910,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
lineHeight: config.lineHeight,
|
||||
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
|
||||
theme: {
|
||||
background: themeColors.background,
|
||||
background: config.backgroundImage
|
||||
? "transparent"
|
||||
: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
cursor: themeColors.cursor,
|
||||
cursorAccent: themeColors.cursorAccent,
|
||||
@@ -1894,7 +1946,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
uri.startsWith("http://") || uri.startsWith("https://")
|
||||
? uri
|
||||
: `https://${uri}`;
|
||||
window.open(url, "_blank");
|
||||
|
||||
const hostBehavior = hostConfig.terminalConfig?.linkClickBehavior;
|
||||
const globalBehavior =
|
||||
localStorage.getItem("terminalLinkClickBehavior") ?? "confirm";
|
||||
const behavior = hostBehavior ?? globalBehavior;
|
||||
|
||||
if (behavior === "direct") {
|
||||
window.open(url, "_blank");
|
||||
} else {
|
||||
setLinkClickDialog({ url });
|
||||
}
|
||||
});
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
@@ -1906,6 +1968,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
terminal.unicode.activeVersion = "11";
|
||||
|
||||
terminal.open(xtermRef.current);
|
||||
terminal.onTitleChange((title) => {
|
||||
if (title) onTitleChange?.(title);
|
||||
});
|
||||
document.fonts.ready.then(() => {
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
fitAddon.fit();
|
||||
@@ -2017,7 +2082,18 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return false;
|
||||
};
|
||||
|
||||
// On macOS Electron, Tab key events can be swallowed by Chromium's focus
|
||||
// traversal system before xterm.js sees them. Calling preventDefault() in
|
||||
// the capture phase blocks that traversal while still allowing the event to
|
||||
// reach xterm.js's internal handler (which fires our attachCustomKeyEventHandler).
|
||||
const handleTabCapture = (e: KeyboardEvent) => {
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
element?.addEventListener("keydown", handleBackspaceMode, true);
|
||||
element?.addEventListener("keydown", handleTabCapture, true);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
@@ -2041,6 +2117,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
element?.removeEventListener("mousemove", handleTmuxDragMove);
|
||||
element?.removeEventListener("mouseup", handleTmuxDragEnd);
|
||||
element?.removeEventListener("keydown", handleBackspaceMode, true);
|
||||
element?.removeEventListener("keydown", handleTabCapture, true);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
};
|
||||
@@ -2096,6 +2173,37 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Forward global app shortcuts to AppShell directly — xterm swallows
|
||||
// all keydown events and synthetic re-dispatch is unreliable.
|
||||
// stopPropagation prevents the same event from also firing the window listener.
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
|
||||
const globalCodes = [
|
||||
"BracketRight",
|
||||
"BracketLeft",
|
||||
"Backslash",
|
||||
"Minus",
|
||||
];
|
||||
if (globalCodes.includes(e.code)) {
|
||||
e.stopPropagation();
|
||||
globalShortcutHandler.current?.(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
||||
const arrowCodes = [
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
];
|
||||
if (arrowCodes.includes(e.code)) {
|
||||
e.stopPropagation();
|
||||
globalShortcutHandler.current?.(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
@@ -2132,6 +2240,38 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key.toLowerCase() === "c"
|
||||
) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key.toLowerCase() === "v"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
@@ -2426,10 +2566,30 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const hasConnectionError = !!connectionError;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative" style={{ backgroundColor }}>
|
||||
<div
|
||||
className="h-full w-full relative"
|
||||
style={{
|
||||
backgroundColor: backgroundImage ? "transparent" : backgroundColor,
|
||||
...(backgroundImage && {
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{backgroundImage && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundColor: themeColors.background,
|
||||
opacity: 1 - backgroundImageOpacity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full"
|
||||
className="h-full w-full relative"
|
||||
style={{
|
||||
pointerEvents: isVisible ? "auto" : "none",
|
||||
visibility:
|
||||
@@ -2656,6 +2816,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}),
|
||||
);
|
||||
}
|
||||
setTimeout(() => terminal?.focus(), 50);
|
||||
}}
|
||||
onCreateNew={() => {
|
||||
setTmuxSessionPicker(null);
|
||||
@@ -2667,6 +2828,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}),
|
||||
);
|
||||
}
|
||||
setTimeout(() => terminal?.focus(), 50);
|
||||
}}
|
||||
onCancel={() => setTmuxSessionPicker(null)}
|
||||
backgroundColor={backgroundColor}
|
||||
@@ -2680,6 +2842,57 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
position={autocompletePosition}
|
||||
onSelect={handleAutocompleteSelect}
|
||||
/>
|
||||
|
||||
{linkClickDialog && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center z-[150]"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col gap-3 p-4 rounded shadow-lg max-w-sm w-full mx-4"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("terminal.linkDialogTitle")}
|
||||
</p>
|
||||
<p className="text-sm break-all text-foreground select-all">
|
||||
{linkClickDialog.url}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
writeTextToClipboard(linkClickDialog.url);
|
||||
setLinkClickDialog(null);
|
||||
}}
|
||||
>
|
||||
{t("terminal.linkDialogCopy")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
linkClickDialog.url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
setLinkClickDialog(null);
|
||||
}}
|
||||
>
|
||||
{t("terminal.linkDialogOpen")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setLinkClickDialog(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||
|
||||
@@ -21,7 +21,9 @@ export interface TerminalHandle {
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
sendInput: (data: string) => void;
|
||||
notifyResize: () => void;
|
||||
refresh: () => void;
|
||||
getApplicationCursorKeysMode: () => boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export type StatusColorScheme = "accent" | "status";
|
||||
|
||||
export function useStatusColorScheme(): StatusColorScheme {
|
||||
const [scheme, setScheme] = useState<StatusColorScheme>(
|
||||
() =>
|
||||
(localStorage.getItem("statusColorScheme") as StatusColorScheme) ??
|
||||
"accent",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setScheme(
|
||||
(localStorage.getItem("statusColorScheme") as StatusColorScheme) ??
|
||||
"accent",
|
||||
);
|
||||
};
|
||||
window.addEventListener("statusColorSchemeChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener("statusColorSchemeChanged", handler);
|
||||
}, []);
|
||||
|
||||
return scheme;
|
||||
}
|
||||
|
||||
/** Returns Tailwind class names for a status dot/stripe. */
|
||||
export function getStatusClasses(
|
||||
online: boolean,
|
||||
scheme: StatusColorScheme,
|
||||
variant: "dot" | "stripe" | "badge",
|
||||
): string {
|
||||
if (scheme === "status") {
|
||||
if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500";
|
||||
if (variant === "stripe")
|
||||
return online ? "bg-emerald-500" : "bg-red-500/40";
|
||||
// badge
|
||||
return online
|
||||
? "border-emerald-500/40 text-emerald-500 bg-emerald-500/10"
|
||||
: "border-red-500/40 text-red-500 bg-red-500/10";
|
||||
}
|
||||
// accent scheme
|
||||
if (variant === "dot")
|
||||
return online ? "bg-accent-brand" : "bg-muted-foreground/25";
|
||||
if (variant === "stripe")
|
||||
return online ? "bg-accent-brand" : "bg-transparent";
|
||||
// badge
|
||||
return online
|
||||
? "border-accent-brand/40 text-accent-brand bg-accent-brand/10"
|
||||
: "border-border/50 text-muted-foreground/60 bg-muted/30";
|
||||
}
|
||||
+21
-11
@@ -1,6 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@fontsource-variable/jetbrains-mono";
|
||||
@import "@fontsource/jetbrains-mono/400.css";
|
||||
@import "@fontsource/jetbrains-mono/400-italic.css";
|
||||
@import "@fontsource/jetbrains-mono/700.css";
|
||||
@import "@fontsource/jetbrains-mono/700-italic.css";
|
||||
@import "@fontsource/fira-code/400.css";
|
||||
@import "@fontsource/fira-code/700.css";
|
||||
@import "@fontsource/source-code-pro/400.css";
|
||||
@import "@fontsource/source-code-pro/400-italic.css";
|
||||
@import "@fontsource/source-code-pro/700.css";
|
||||
@import "@fontsource/source-code-pro/700-italic.css";
|
||||
|
||||
@font-face {
|
||||
font-family: "Caskaydia Cove Nerd Font Mono";
|
||||
@@ -242,38 +252,38 @@
|
||||
.nord {
|
||||
--background: #2e3440;
|
||||
--foreground: #eceff4;
|
||||
--card: #272c36;
|
||||
--card: #303642;
|
||||
--card-foreground: #eceff4;
|
||||
--popover: #272c36;
|
||||
--popover: #303642;
|
||||
--popover-foreground: #eceff4;
|
||||
--primary: #eceff4;
|
||||
--primary-foreground: #2e3440;
|
||||
--secondary: #3b4252;
|
||||
--secondary-foreground: #eceff4;
|
||||
--surface: #272c36;
|
||||
--surface-dim: #22262e;
|
||||
--surface: #303642;
|
||||
--surface-dim: #262b35;
|
||||
--muted: #3b4252;
|
||||
--muted-foreground: #4c566a;
|
||||
--muted-foreground: #8899b0;
|
||||
--accent: #3b4252;
|
||||
--accent-foreground: #eceff4;
|
||||
--destructive: #bf616a;
|
||||
--border: rgba(76, 86, 106, 0.35);
|
||||
--input: rgba(76, 86, 106, 0.3);
|
||||
--ring: #4c566a;
|
||||
--border: rgba(136, 192, 208, 0.2);
|
||||
--input: rgba(136, 192, 208, 0.15);
|
||||
--ring: #88c0d0;
|
||||
--chart-1: #bf616a;
|
||||
--chart-2: #88c0d0;
|
||||
--chart-3: #a3be8c;
|
||||
--chart-4: #ebcb8b;
|
||||
--chart-5: #b48ead;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #272c36;
|
||||
--sidebar: #282d39;
|
||||
--sidebar-foreground: #eceff4;
|
||||
--sidebar-primary: #88c0d0;
|
||||
--sidebar-primary-foreground: #2e3440;
|
||||
--sidebar-accent: #3b4252;
|
||||
--sidebar-accent-foreground: #eceff4;
|
||||
--sidebar-border: rgba(76, 86, 106, 0.35);
|
||||
--sidebar-ring: #4c566a;
|
||||
--sidebar-border: rgba(136, 192, 208, 0.2);
|
||||
--sidebar-ring: #88c0d0;
|
||||
}
|
||||
|
||||
/* ── Solarized Dark ─────────────────────────────────────────── */
|
||||
|
||||
@@ -18,9 +18,13 @@ export class RobustClipboardProvider implements IClipboardProvider {
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
this.pendingWrite = text;
|
||||
});
|
||||
} else {
|
||||
this.pendingWrite = text;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("focus", this.focusHandler);
|
||||
@@ -47,7 +51,11 @@ export class RobustClipboardProvider implements IClipboardProvider {
|
||||
await window.electronClipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
this.pendingWrite = text;
|
||||
}
|
||||
} catch {
|
||||
this.pendingWrite = text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Module-level ref so xterm's key handler can invoke app-level shortcuts
|
||||
// without going through synthetic DOM events.
|
||||
export const globalShortcutHandler = {
|
||||
current: null as ((e: KeyboardEvent) => void) | null,
|
||||
};
|
||||
@@ -154,7 +154,7 @@ describe("highlightTerminalOutput", () => {
|
||||
});
|
||||
|
||||
it("processes multi-line text line by line", () => {
|
||||
const text = "some output\nERROR: failed";
|
||||
const text = "some output\nERROR: failed\n";
|
||||
const out = highlightTerminalOutput(text);
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
@@ -198,4 +198,91 @@ describe("highlightTerminalOutput", () => {
|
||||
});
|
||||
expect(out).not.toContain(ESC + "[96m");
|
||||
});
|
||||
|
||||
it("skips highlighting when chunk contains a mid-line carriage return", () => {
|
||||
// Progress bars and shell prompt redraws use \r to overwrite the current line
|
||||
const chunk = "downloading...\rdownloading [====] 100%";
|
||||
expect(highlightTerminalOutput(chunk)).toBe(chunk);
|
||||
});
|
||||
|
||||
it("still processes CRLF line endings (\\r\\n is fine)", () => {
|
||||
const out = highlightTerminalOutput("ERROR occurred\r\n");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("does not highlight shell prompt lines (user@host:path$)", () => {
|
||||
const prompt = `${ESC}[01;32mpi@raspberrypi${ESC}[00m:${ESC}[01;34m/home/pi${ESC}[00m$ `;
|
||||
const out = highlightTerminalOutput(prompt);
|
||||
expect(out).toBe(prompt);
|
||||
});
|
||||
|
||||
it("does not highlight plain-text shell prompt line", () => {
|
||||
const prompt = "pi@raspberrypi:/home/pi$ ";
|
||||
const out = highlightTerminalOutput(prompt);
|
||||
expect(out).toBe(prompt);
|
||||
});
|
||||
|
||||
it("does not highlight 'success' when immediately followed by a path (cd output)", () => {
|
||||
// Some shells print "success~/new/dir" or "success/path" after a cd command
|
||||
const out = highlightTerminalOutput("success~/home/user/projects");
|
||||
expect(out).not.toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("still highlights 'success' when not followed by a path separator", () => {
|
||||
const out = highlightTerminalOutput("Build success: all tests passed");
|
||||
expect(out).toContain(ESC + "[92m");
|
||||
});
|
||||
|
||||
it("highlights output lines but not the prompt in a mixed chunk", () => {
|
||||
const chunk = `ERROR: disk full\npi@raspberrypi:~$ `;
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
// The error line should be highlighted
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
// The prompt line should be unchanged
|
||||
expect(out).toContain("pi@raspberrypi:~$ ");
|
||||
const promptLine = out.split("\n")[1];
|
||||
expect(promptLine).toBe("pi@raspberrypi:~$ ");
|
||||
});
|
||||
|
||||
it("does not highlight paths inside a command-echo line (prompt + command)", () => {
|
||||
// When the shell echoes the user's command, it prefixes the prompt.
|
||||
// The path in the prompt portion (/home/user) must not be highlighted —
|
||||
// the shell already colored it, and re-coloring it causes the doubled-path bug.
|
||||
const echo = "user@host:/home/user$ cd /opt/app/bin/files";
|
||||
const out = highlightTerminalOutput(echo);
|
||||
expect(out).toBe(echo);
|
||||
});
|
||||
|
||||
it("does not highlight paths in colored command-echo lines (root prompt)", () => {
|
||||
const echo = `${ESC}[01;32mroot@host${ESC}[00m:${ESC}[01;34m/home/user${ESC}[00m# cd /opt/app/bin/files`;
|
||||
const out = highlightTerminalOutput(echo);
|
||||
expect(out).toBe(echo);
|
||||
});
|
||||
|
||||
it("does not highlight the last line of a multi-line chunk with no trailing newline", () => {
|
||||
// In a multi-line chunk, the trailing fragment without \n could be a live
|
||||
// readline input line. Injecting ANSI bytes there breaks bash's cursor
|
||||
// arithmetic (causes cursor jump / text shift when using arrow keys).
|
||||
const chunk = "ERROR: disk full\nuser@host:/path$ cd /var/log";
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
// First line (terminated by \n) gets highlighted
|
||||
expect(out.split("\n")[0]).toContain(ESC + "[91m");
|
||||
// Last unterminated fragment is left verbatim
|
||||
expect(out.split("\n")[1]).toBe("user@host:/path$ cd /var/log");
|
||||
});
|
||||
|
||||
it("still highlights a single-line chunk with no trailing newline", () => {
|
||||
// A single-line chunk with no \n is plain command output, not a readline
|
||||
// input fragment — highlight it normally.
|
||||
const out = highlightTerminalOutput("ERROR: something failed");
|
||||
expect(out).toContain(ESC + "[91m");
|
||||
});
|
||||
|
||||
it("highlights all lines when the chunk ends with a newline", () => {
|
||||
// When a chunk ends with \n every line is complete output — highlight them all.
|
||||
const chunk = "ERROR: disk full\nconnect to /var/run/app.sock\n";
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
expect(out.split("\n")[0]).toContain(ESC + "[91m");
|
||||
expect(out.split("\n")[1]).toContain(ESC + "[36m");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,28 @@ const MAX_LINE_LENGTH = 2000;
|
||||
// If a chunk contains these, we skip highlighting entirely.
|
||||
const TUI_SEQUENCE = /\x1b\[[\d;]*[ABCDEFGHJKST]/;
|
||||
|
||||
// A bare \r (not immediately followed by \n) means the terminal is overwriting
|
||||
// the current line (shell prompts, progress bars). Highlighting mid-rewrite
|
||||
// chunks corrupts the cursor state, so we skip the whole chunk.
|
||||
const MID_LINE_CR = /\r(?!\n)/;
|
||||
|
||||
// Detects shell prompt lines (user@host:/path$ or similar) after stripping ANSI.
|
||||
// These should not be highlighted — the server already colored them, and injecting
|
||||
// additional ANSI codes into the prompt fragments causes display corruption.
|
||||
const STRIP_ANSI_RE = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
|
||||
|
||||
function isShellPromptLine(bare: string): boolean {
|
||||
const plain = bare.replace(STRIP_ANSI_RE, "");
|
||||
// Matches a trailing prompt: "user@host:~$ ", "root@pi:/home/pi# ", "[user@host dir]$ "
|
||||
if (/(?:[\w.-]+@[\w.-]+|[\w.-]+).*?[$#%>]\s*$/.test(plain)) return true;
|
||||
// Matches a leading prompt followed by a command: "user@host:/path$ cmd arg"
|
||||
// This is the echoed command line — the shell colors the prompt prefix itself,
|
||||
// so injecting extra ANSI codes into it causes visual corruption / doubled paths.
|
||||
if (/^(?:\[?[\w.-]+@[\w.-]+[\w./ ~-]*\]?|[\w.-]+).*?[$#%>]\s+\S/.test(plain))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Matches any complete ANSI escape sequence
|
||||
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9;?>=!]*[@-~])/g;
|
||||
|
||||
@@ -114,10 +136,13 @@ const ALL_PATTERNS: HighlightPattern[] = [
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Success keywords — kept conservative to avoid noise
|
||||
// Success keywords — kept conservative to avoid noise.
|
||||
// Negative lookahead prevents matching when directly followed by a path separator
|
||||
// (e.g. shell `cd` output that prints "success~/new/dir").
|
||||
{
|
||||
name: "log-success",
|
||||
regex: /\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b/gi,
|
||||
regex:
|
||||
/\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b(?![\\/~])/gi,
|
||||
ansiCode: ANSI.brightGreen,
|
||||
priority: 8,
|
||||
category: "logLevels",
|
||||
@@ -313,6 +338,7 @@ function highlightLine(
|
||||
const bare = cr ? line.slice(0, -1) : line;
|
||||
|
||||
if (!bare.trim()) return line;
|
||||
if (isShellPromptLine(bare)) return line;
|
||||
|
||||
const segments = parseAnsiSegments(bare);
|
||||
const result = segments
|
||||
@@ -334,12 +360,29 @@ export function highlightTerminalOutput(
|
||||
if (hasIncompleteAnsiSequence(text)) return text;
|
||||
|
||||
if (TUI_SEQUENCE.test(text)) return text;
|
||||
if (MID_LINE_CR.test(text)) return text;
|
||||
|
||||
const activePatterns = buildActivePatterns(options);
|
||||
if (activePatterns.length === 0) return text;
|
||||
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => highlightLine(line, activePatterns))
|
||||
const lines = text.split("\n");
|
||||
const endsWithNewline = text.endsWith("\n");
|
||||
const hasMultipleLines = lines.length > 1;
|
||||
|
||||
// When a multi-line chunk has a trailing fragment without \n, that fragment
|
||||
// could be a live readline input line that bash will redraw with \r +
|
||||
// cursor-positioning sequences. Injecting ANSI bytes into it adds invisible
|
||||
// chars that bash doesn't count, so bash's cursor arithmetic diverges from
|
||||
// xterm's actual column position — causing the cursor to jump and text to
|
||||
// shift when the user types or navigates with arrow keys.
|
||||
// Only skip the last fragment when the chunk already has complete lines
|
||||
// before it (single-line chunks with no \n are plain output, not input).
|
||||
const highlightCount =
|
||||
hasMultipleLines && !endsWithNewline ? lines.length - 1 : lines.length;
|
||||
|
||||
return lines
|
||||
.map((line, i) =>
|
||||
i < highlightCount ? highlightLine(line, activePatterns) : line,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
@@ -821,6 +821,8 @@ export const DEFAULT_TERMINAL_CONFIG = {
|
||||
keepaliveInterval: undefined as number | undefined,
|
||||
keepaliveCountMax: undefined as number | undefined,
|
||||
autoTmux: false,
|
||||
backgroundImage: "" as string,
|
||||
backgroundImageOpacity: 0.15,
|
||||
};
|
||||
|
||||
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
||||
|
||||
@@ -41,6 +41,12 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
|
||||
description: "Visual map of host network topology",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "service_links",
|
||||
label: "Service Links",
|
||||
description: "Clickable buttons linking to services on your servers",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const ACCENT_PRESET_COLORS = [
|
||||
|
||||
+216
-12
@@ -58,7 +58,10 @@
|
||||
"embeddedDesc": "Run Termix with the built-in local server (no remote server needed)",
|
||||
"embeddedConnecting": "Connecting to local server...",
|
||||
"embeddedNotReady": "Local server is not ready yet. Please wait a moment and try again.",
|
||||
"localServer": "Local Server"
|
||||
"localServer": "Local Server",
|
||||
"savedServers": "Saved Servers",
|
||||
"noSavedServers": "No saved servers",
|
||||
"removeServer": "Remove"
|
||||
},
|
||||
"versionCheck": {
|
||||
"error": "Version Check Error",
|
||||
@@ -105,6 +108,7 @@
|
||||
"cancel": "Cancel",
|
||||
"username": "Username",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"register": "Register",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
@@ -165,6 +169,7 @@
|
||||
"noPasswordAvailable": "No password available",
|
||||
"failedToCopyPassword": "Failed to copy password",
|
||||
"refreshTab": "Refresh connection",
|
||||
"renameTab": "Rename tab",
|
||||
"openFileManager": "Open File Manager",
|
||||
"dashboard": "Dashboard",
|
||||
"networkGraph": "Network Graph",
|
||||
@@ -190,6 +195,7 @@
|
||||
"downloadSample": "Download Sample",
|
||||
"failedToDeleteHost": "Failed to delete {{name}}",
|
||||
"importSkipExisting": "Import (skip existing)",
|
||||
"importSSHConfig": "Import from SSH config",
|
||||
"connectionDetails": "Connection Details",
|
||||
"ssh": "SSH",
|
||||
"telnet": "Telnet",
|
||||
@@ -264,6 +270,7 @@
|
||||
"forceKeyboardInteractive": "Force Keyboard-Interactive",
|
||||
"overrideCredentialUsername": "Override Credential Username",
|
||||
"overrideCredentialUsernameDesc": "Use the username specified above instead of the credential's username",
|
||||
"oidcUsernameHint": "Use $oidc.preferred_username to substitute your OIDC login name.",
|
||||
"jumpHostChain": "Jump Host Chain",
|
||||
"portKnocking": "Port Knocking",
|
||||
"addKnock": "Add Port",
|
||||
@@ -297,6 +304,8 @@
|
||||
"addressIp": "Address / IP",
|
||||
"friendlyName": "Friendly Name",
|
||||
"macAddress": "MAC Address",
|
||||
"wolBroadcastAddress": "WoL Broadcast Address",
|
||||
"wolBroadcastAddressDesc": "Optional directed broadcast for Docker/routed networks (e.g. 192.168.1.255). Leave empty to use 255.255.255.255.",
|
||||
"folderAndAdvanced": "Folder & Advanced",
|
||||
"privateNotes": "Private Notes",
|
||||
"privateNotesPlaceholder": "Details about this server...",
|
||||
@@ -339,6 +348,8 @@
|
||||
"docsLink": "View docs",
|
||||
"opksshLabel": "OPKSSH",
|
||||
"opksshDesc": "Sign in to this host using your identity provider instead of a password or key. Requires OPKSSH set up on the server.",
|
||||
"warpgateLabel": "Warpgate Gateway",
|
||||
"warpgateDesc": "This host connects through a Warpgate SSH proxy. Termix will handle the browser-based approval flow automatically after authenticating.",
|
||||
"tailscaleDeviceSelect": "Select Tailscale device",
|
||||
"tailscaleDeviceSelectPlaceholder": "Select a device...",
|
||||
"tailscaleNoApiKey": "No Tailscale API key configured. Add one in Admin Settings to enable device discovery.",
|
||||
@@ -348,6 +359,9 @@
|
||||
"tailscaleDeviceAutoFill": "Selecting a device will auto-fill the host IP address.",
|
||||
"forceKeyboardInteractiveLabel": "Force Keyboard Interactive",
|
||||
"forceKeyboardInteractiveShortDesc": "Force manual password entry even if keys are present",
|
||||
"allowLegacyAlgorithmsLabel": "Allow Legacy Algorithms",
|
||||
"allowLegacyAlgorithmsDesc": "Enable deprecated SSH algorithms (ssh-dss, ssh-rsa, diffie-hellman-group1-sha1, hmac-md5, 3des-cbc) for connections to old devices that cannot be upgraded.",
|
||||
"insecure": "Insecure",
|
||||
"terminalAppearance": "Terminal Appearance",
|
||||
"colorTheme": "Color Theme",
|
||||
"fontFamilyLabel": "Font Family",
|
||||
@@ -361,6 +375,9 @@
|
||||
"cursorBlinkingDesc": "Enable blinking animation for the terminal cursor",
|
||||
"rightClickSelectsWordLabel": "Right-click Selects Word",
|
||||
"rightClickSelectsWordShortDesc": "Select the word under cursor on right-click",
|
||||
"backgroundImageLabel": "Background Image URL",
|
||||
"backgroundImageDesc": "Optional URL for a terminal background image",
|
||||
"backgroundImageOpacityLabel": "Background Image Opacity",
|
||||
"syntaxHighlightingLabel": "Syntax Highlighting",
|
||||
"syntaxHighlightingDesc": "Colorize terminal output (errors, paths, IPs, timestamps)",
|
||||
"syntaxHighlightingCategories": "Highlight Categories",
|
||||
@@ -382,6 +399,8 @@
|
||||
"scrollbackMaxLines": "Maximum number of lines kept in history",
|
||||
"sshAgentForwardingLabel": "SSH Agent Forwarding",
|
||||
"sshAgentForwardingShortDesc": "Pass your local SSH keys to this host",
|
||||
"useSSHTitleLabel": "Use SSH Window Title",
|
||||
"useSSHTitleDesc": "Update the tab title from the shell's window title instead of the host name",
|
||||
"enableAutoMosh": "Enable Auto-Mosh",
|
||||
"enableAutoMoshDesc": "Prefer Mosh over SSH if available",
|
||||
"enableAutoTmux": "Enable Auto-Tmux",
|
||||
@@ -390,6 +409,11 @@
|
||||
"enableSessionLoggingDesc": "Record terminal session output for later review",
|
||||
"enableCommandHistory": "Command History",
|
||||
"enableCommandHistoryDesc": "Record commands run in this terminal for history and autocomplete",
|
||||
"linkClickBehaviorLabel": "Link Click Behavior",
|
||||
"linkClickBehaviorDesc": "Controls what happens when you click a link in the terminal. Use 'Default' to follow the app-level setting.",
|
||||
"linkClickBehaviorDefault": "Default (follow app setting)",
|
||||
"linkClickBehaviorConfirm": "Show popup to open or copy",
|
||||
"linkClickBehaviorDirect": "Open directly",
|
||||
"sudoPasswordAutoFillLabel": "Sudo Password Auto-fill",
|
||||
"sudoPasswordAutoFillShortDesc": "Automatically provide sudo password when prompted",
|
||||
"sudoPasswordAutoFillDesc": "Store a sudo password so host metrics, terminal prompts, and others can run privileged commands automatically.",
|
||||
@@ -436,8 +460,15 @@
|
||||
"proxmoxIntegration": "Proxmox Integration",
|
||||
"enableProxmox": "Enable Proxmox",
|
||||
"enableProxmoxDesc": "Mark this host as a Proxmox node. Enables guest discovery and import directly from this host.",
|
||||
"proxmoxDefaultAuthType": "Default Auth Type",
|
||||
"proxmoxDefaultAuthTypeDesc": "Authentication method applied to imported guest hosts. Choose the auth type that matches how you connect to your guests.",
|
||||
"authTypePassword": "Password",
|
||||
"authTypeKey": "SSH Key",
|
||||
"authTypeCredential": "Credential",
|
||||
"authTypeOpkssh": "OPKSSH",
|
||||
"authTypeNone": "None",
|
||||
"proxmoxDefaultCredential": "Default Credential",
|
||||
"proxmoxDefaultCredentialDesc": "Credential used for imported guest hosts. The credential's username is applied to all guests.",
|
||||
"proxmoxDefaultCredentialDesc": "Credential used for imported guest hosts when auth type is set to Credential.",
|
||||
"proxmoxWindowsDetection": "Windows / RDP detection",
|
||||
"proxmoxWindowsDetectionDesc": "Comma-separated name patterns that trigger RDP instead of SSH (case-insensitive)",
|
||||
"proxmoxDockerDetection": "Docker detection",
|
||||
@@ -465,6 +496,8 @@
|
||||
"proxmoxImportFailed": "Import failed",
|
||||
"enableFileManagerMonitor": "Enable File Manager",
|
||||
"enableFileManagerMonitorDesc": "Browse and manage files on this host over SFTP",
|
||||
"scpLegacyLabel": "SCP Legacy Mode",
|
||||
"scpLegacyDesc": "Use legacy file transfer for servers that do not support the SFTP subsystem (e.g. embedded or minimal SSH servers).",
|
||||
"defaultPathLabel": "Default Path",
|
||||
"fileManagerPathHint": "The directory to open when the file manager launches for this host.",
|
||||
"statusChecksLabel": "Status Checks",
|
||||
@@ -522,6 +555,7 @@
|
||||
"credentialUpdated": "Credential updated",
|
||||
"credentialCreated": "Credential created",
|
||||
"failedToSaveCredential": "Failed to save credential",
|
||||
"credentialNameRequired": "Please enter a name for the credential",
|
||||
"backToHosts": "Back to Hosts",
|
||||
"backToCredentials": "Back to Credentials",
|
||||
"pinned": "Pinned",
|
||||
@@ -686,6 +720,7 @@
|
||||
"nSelected": "{{count}} selected",
|
||||
"featuresMenu": "Features",
|
||||
"moveMenu": "Move",
|
||||
"connectSelected": "Connect",
|
||||
"cancelSelection": "Cancel",
|
||||
"deployDialogDesc": "Deploy {{name}} to a host's authorized_keys.",
|
||||
"targetHostLabel": "Target Host",
|
||||
@@ -746,6 +781,12 @@
|
||||
"guac": {
|
||||
"connection": "Connection",
|
||||
"authentication": "Authentication",
|
||||
"storedCredential": "Stored Credential",
|
||||
"noCredential": "No credential (direct credentials below)",
|
||||
"authMethod": "Auth Method",
|
||||
"authTypeDirect": "Direct",
|
||||
"authTypeCredential": "Credential",
|
||||
"selectCredential": "Select a credential...",
|
||||
"connectionSettings": "Connection Settings",
|
||||
"displaySettings": "Display Settings",
|
||||
"audioSettings": "Audio Settings",
|
||||
@@ -773,6 +814,13 @@
|
||||
"initialProgram": "Initial Program",
|
||||
"serverLayout": "Server Layout",
|
||||
"timezone": "Timezone",
|
||||
"loadBalanceInfo": "Load Balance Info / Cookie",
|
||||
"loadBalanceInfoDesc": "RD Connection Broker cookie for RDS farm load balancing (e.g. tsv://MS Terminal Services Plugin.1.CollectionName)",
|
||||
"guacdProxy": "guacd Proxy",
|
||||
"guacdHostname": "guacd Host",
|
||||
"guacdHostnamePlaceholder": "Global default",
|
||||
"guacdPort": "guacd Port",
|
||||
"guacdProxyDesc": "Override the global guacd instance for this connection. Leave blank to use the globally configured guacd.",
|
||||
"gatewayHostname": "Gateway Hostname",
|
||||
"gatewayPort": "Gateway Port",
|
||||
"gatewayUsername": "Gateway Username",
|
||||
@@ -937,7 +985,8 @@
|
||||
"persisted": "Persisted in background",
|
||||
"expiresIn": "Expires in {{duration}}",
|
||||
"search": "Search connections...",
|
||||
"noSearchResults": "No connections match your search"
|
||||
"noSearchResults": "No connections match your search",
|
||||
"rename": "Rename session"
|
||||
},
|
||||
"guacamole": {
|
||||
"connecting": "Connecting to {{type}} session...",
|
||||
@@ -1034,6 +1083,9 @@
|
||||
"opksshAuthFailed": "Authentication failed. Please check your credentials and try again.",
|
||||
"opksshSignInWith": "Sign in with {{provider}}",
|
||||
"sudoPasswordPopupTitle": "Insert Password?",
|
||||
"linkDialogTitle": "Open Link",
|
||||
"linkDialogOpen": "Open",
|
||||
"linkDialogCopy": "Copy",
|
||||
"websocketAbnormalClose": "Connection closed unexpectedly. This may be due to a reverse proxy or SSL configuration issue. Please check server logs.",
|
||||
"connectionLogTitle": "Connection Log",
|
||||
"connectionLogCopy": "Copy logs to clipboard",
|
||||
@@ -1043,7 +1095,12 @@
|
||||
"connectionLogCopyFailed": "Failed to copy logs to clipboard",
|
||||
"connectionRejected": "Connection rejected by server. Please check your authentication and network configuration.",
|
||||
"hostKeyRejected": "SSH host key verification rejected. Connection cancelled.",
|
||||
"sessionTakenOver": "Session was opened in another tab. Reconnecting..."
|
||||
"sessionTakenOver": "Session was opened in another tab. Reconnecting...",
|
||||
"split": {
|
||||
"splitTab": "Split Tab",
|
||||
"addToSplit": "Add to Split",
|
||||
"removeFromSplit": "Remove from Split"
|
||||
}
|
||||
},
|
||||
"fileManager": {
|
||||
"noHostSelected": "No host selected",
|
||||
@@ -1124,6 +1181,10 @@
|
||||
"pathCopiedToClipboard": "Path copied to clipboard",
|
||||
"pathsCopiedToClipboard": "{{count}} paths copied to clipboard",
|
||||
"failedToCopyPath": "Failed to copy path to clipboard",
|
||||
"copyFolderLink": "Copy Link to Folder",
|
||||
"copyCurrentFolderLink": "Copy Link to Current Folder",
|
||||
"folderLinkCopied": "Folder link copied to clipboard",
|
||||
"failedToCopyFolderLink": "Failed to copy folder link",
|
||||
"movedItems": "Moved {{count}} items",
|
||||
"failedToDeleteItem": "Failed to delete item",
|
||||
"itemRenamedSuccessfully": "{{type}} renamed successfully",
|
||||
@@ -1233,6 +1294,8 @@
|
||||
"move": "Move",
|
||||
"searchInFile": "Search in file (Ctrl+F)",
|
||||
"showKeyboardShortcuts": "Show keyboard shortcuts",
|
||||
"decreaseFontSize": "Decrease font size",
|
||||
"increaseFontSize": "Increase font size",
|
||||
"startWritingMarkdown": "Start writing your markdown content...",
|
||||
"loadingFileComparison": "Loading file comparison...",
|
||||
"reload": "Reload",
|
||||
@@ -1248,6 +1311,7 @@
|
||||
"totpVerificationFailed": "TOTP verification failed",
|
||||
"warpgateVerificationFailed": "Warpgate authentication failed",
|
||||
"authenticationFailed": "Authentication failed",
|
||||
"incorrectPassphrase": "Incorrect passphrase. Please try again.",
|
||||
"verificationCodePrompt": "Verification code:",
|
||||
"changePermissions": "Change Permissions",
|
||||
"currentPermissions": "Current Permissions",
|
||||
@@ -1632,7 +1696,7 @@
|
||||
},
|
||||
"auth": {
|
||||
"tagline": "Self-hosted SSH and remote desktop management",
|
||||
"loginTitle": "Login to Termix",
|
||||
"loginTitle": "Welcome back",
|
||||
"registerTitle": "Create Account",
|
||||
"forgotPassword": "Forgot Password?",
|
||||
"rememberMe": "Remember Device for 30 Days (includes TOTP)",
|
||||
@@ -1684,6 +1748,7 @@
|
||||
"dataLossWarning": "Resetting your password this way will delete all your saved SSH hosts, credentials, and other encrypted data. This action cannot be undone. Only use this if you have forgotten your password and are not logged in.",
|
||||
"authenticationDisabled": "Authentication Disabled",
|
||||
"authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator.",
|
||||
"passwordLoginDisabledDesc": "Password login is disabled. Please use an external authentication provider below.",
|
||||
"attemptsRemaining": "{{count}} attempts remaining"
|
||||
},
|
||||
"hostKey": {
|
||||
@@ -1812,7 +1877,8 @@
|
||||
"serverStatsCard": "Server Stats",
|
||||
"panelMain": "Main",
|
||||
"panelSide": "Side",
|
||||
"justNow": "just now"
|
||||
"justNow": "just now",
|
||||
"serviceLinks": "Service Links"
|
||||
},
|
||||
"dashboardTab": {
|
||||
"stable": "STABLE",
|
||||
@@ -1833,7 +1899,17 @@
|
||||
"done": "Done",
|
||||
"editModeInstructions": "Drag cards to reorder · Drag the column divider to resize columns · Drag the bottom edge of a card to resize its height · Trash to remove",
|
||||
"empty": "Empty",
|
||||
"clear": "Clear"
|
||||
"clear": "Clear",
|
||||
"serviceLinksTitle": "Service Links",
|
||||
"serviceLinksEmpty": "No service links yet",
|
||||
"serviceLinksAddLabel": "Label",
|
||||
"serviceLinksAddUrl": "URL",
|
||||
"serviceLinksAdd": "Add",
|
||||
"serviceLinksLabelPlaceholder": "My Service",
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Must be a valid http or https URL",
|
||||
"disk": "Disk",
|
||||
"viewServerDetails": "View server details"
|
||||
},
|
||||
"sessionLogs": {
|
||||
"title": "Session Logs",
|
||||
@@ -2036,6 +2112,38 @@
|
||||
"sectionDatabase": "Database",
|
||||
"sectionApiKeys": "API Keys",
|
||||
"sectionAuditLog": "Audit Log",
|
||||
"sectionSsl": "SSL / Let's Encrypt",
|
||||
"sslDescription": "Automatically issue and renew a trusted SSL certificate from Let's Encrypt. Requires a public domain and port 80 or DNS access.",
|
||||
"sslDocsLink": "View SSL docs",
|
||||
"sslDomain": "Domain",
|
||||
"sslDomainPlaceholder": "termix.example.com",
|
||||
"sslDomainDesc": "The public domain name for the certificate.",
|
||||
"sslEmail": "Email",
|
||||
"sslEmailPlaceholder": "admin@example.com",
|
||||
"sslEmailDesc": "Contact email for Let's Encrypt notifications and account.",
|
||||
"sslChallengeType": "Challenge Type",
|
||||
"sslChallengeTypeDesc": "How to prove domain ownership to Let's Encrypt.",
|
||||
"sslChallengeHttp": "HTTP (webroot) - requires port 80 accessible from the internet",
|
||||
"sslChallengeDns": "DNS (Cloudflare) - requires a Cloudflare API token",
|
||||
"sslCloudflareToken": "Cloudflare API Token",
|
||||
"sslCloudflareTokenPlaceholder": "Enter token...",
|
||||
"sslCloudflareTokenDesc": "Scoped token with Zone:DNS:Edit permission for your domain.",
|
||||
"sslCertStatus": "Certificate Status",
|
||||
"sslCertStatusNone": "No certificate",
|
||||
"sslCertStatusValid": "Valid",
|
||||
"sslCertStatusExpiring": "Expiring soon",
|
||||
"sslCertStatusExpired": "Expired",
|
||||
"sslCertExpiresAt": "Expires {{date}}",
|
||||
"sslLastIssued": "Last issued {{date}}",
|
||||
"sslRequestCert": "Issue / Renew Certificate",
|
||||
"sslRequestCertLoading": "Requesting certificate...",
|
||||
"sslRequestCertSuccess": "Certificate issued and installed successfully",
|
||||
"sslRequestCertFailed": "Certificate request failed",
|
||||
"sslSave": "Save Settings",
|
||||
"sslSaved": "SSL settings saved",
|
||||
"sslSaveFailed": "Failed to save SSL settings",
|
||||
"sslRequiresDomain": "Domain and email are required",
|
||||
"sslInfoNote": "After issuing a certificate, enable SSL in your environment variables (ENABLE_SSL=true) and restart Termix.",
|
||||
"auditLogTotal": "{{total}} total entries",
|
||||
"auditLogEmpty": "No audit log entries found",
|
||||
"auditLogSuccess": "Success",
|
||||
@@ -2052,11 +2160,13 @@
|
||||
"auditLogFilterTo": "To",
|
||||
"auditLogFilterAll": "All",
|
||||
"allowRegistration": "Allow User Registration",
|
||||
"allowRegistrationDesc": "Let new users self-register",
|
||||
"allowRegistrationDesc": "Let new users self-register with a username and password",
|
||||
"allowPasswordLogin": "Allow Password Login",
|
||||
"allowPasswordLoginDesc": "Username/password login",
|
||||
"oidcAutoProvision": "OIDC Auto-Provision",
|
||||
"oidcAutoProvisionDesc": "Auto-create accounts for OIDC users even when registration is disabled",
|
||||
"oidcAutoProvisionDesc": "Auto-create accounts for OIDC/SSO users on first login (independent of the registration toggle)",
|
||||
"oidcSilentLoginDefault": "Silent OIDC Login by Default",
|
||||
"oidcSilentLoginDefaultDesc": "Automatically redirect to OIDC login on every visit, skipping the login form entirely",
|
||||
"allowPasswordReset": "Allow Password Reset",
|
||||
"allowPasswordResetDesc": "Reset code via Docker logs",
|
||||
"commandHistoryEnabled": "Command History",
|
||||
@@ -2094,6 +2204,8 @@
|
||||
"oidcAdminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"oidcGroupClaim": "Group Claim",
|
||||
"oidcGroupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"oidcCaCert": "Custom CA Certificate",
|
||||
"oidcCaCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store.",
|
||||
"removeOidc": "Remove",
|
||||
"usersCount": "{{count}} users",
|
||||
"createUser": "Create",
|
||||
@@ -2193,10 +2305,19 @@
|
||||
"linkAccountSuccess": "Accounts linked successfully",
|
||||
"linkAccountFailed": "Failed to link accounts",
|
||||
"linkAccountInProgress": "Linking...",
|
||||
"unlinkAccountTitle": "Unlink OIDC",
|
||||
"unlinkAccountDesc": "Remove OIDC authentication from {{username}}. They will only be able to log in with their password.",
|
||||
"unlinkAccountWarning": "This will remove OIDC login from this account. The user must have a password set to continue logging in.",
|
||||
"unlinkAccount": "Unlink OIDC",
|
||||
"unlinkAccountInProgress": "Unlinking...",
|
||||
"unlinkAccountSuccess": "OIDC unlinked successfully",
|
||||
"unlinkAccountFailed": "Failed to unlink OIDC",
|
||||
"saving": "Saving...",
|
||||
"updateRegistrationFailed": "Failed to update registration setting",
|
||||
"updatePasswordLoginFailed": "Failed to update password login setting",
|
||||
"cannotDisablePasswordLoginWithTotp": "Cannot disable password login while 2FA is enabled for one or more users. Disable 2FA first.",
|
||||
"updateOidcAutoProvisionFailed": "Failed to update OIDC auto-provision setting",
|
||||
"updateOidcSilentLoginDefaultFailed": "Failed to update silent OIDC login setting",
|
||||
"updatePasswordResetFailed": "Failed to update password reset setting",
|
||||
"sessionTimeoutRange2": "Session timeout must be between 1 and 720 hours",
|
||||
"sessionTimeoutSaved": "Session timeout saved",
|
||||
@@ -2233,7 +2354,27 @@
|
||||
"importSelectFile": "Please select a file first",
|
||||
"importCompleted": "Import completed: {{total}} items imported, {{skipped}} skipped",
|
||||
"importFailed": "Import failed: {{error}}",
|
||||
"importError": "Database import failed"
|
||||
"importError": "Database import failed",
|
||||
"sectionHostDefaults": "Host Defaults",
|
||||
"hostDefaultsDesc": "Settings applied automatically when creating a new host. Individual hosts can still override these.",
|
||||
"hostDefaultsSocks5": "SOCKS5 Proxy",
|
||||
"hostDefaultsUseSocks5": "Enable SOCKS5 Proxy",
|
||||
"hostDefaultsUseSocks5Desc": "Pre-fill SOCKS5 proxy on all new hosts",
|
||||
"hostDefaultsSocks5Host": "Proxy Host / Port",
|
||||
"hostDefaultsSocks5Username": "Proxy Username",
|
||||
"hostDefaultsSocks5Password": "Proxy Password",
|
||||
"hostDefaultsMetrics": "Host Metrics",
|
||||
"hostDefaultsMetricsEnabled": "Enable Metrics",
|
||||
"hostDefaultsMetricsEnabledDesc": "Collect CPU, memory, and other stats on new hosts by default",
|
||||
"hostDefaultsStatusCheckEnabled": "Enable Status Check",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Poll online/offline status on new hosts by default",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
"hostDefaultsSessionLogging": "Session Logging",
|
||||
"hostDefaultsSessionLoggingDesc": "Record terminal sessions on new hosts by default",
|
||||
"hostDefaultsCommandHistory": "Command History",
|
||||
"hostDefaultsCommandHistoryDesc": "Track command history on new hosts by default",
|
||||
"hostDefaultsSaved": "Host defaults saved",
|
||||
"hostDefaultsSaveFailed": "Failed to save host defaults"
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
@@ -2292,7 +2433,13 @@
|
||||
"splitTab": "Split Tab",
|
||||
"addToSplit": "Add to Split",
|
||||
"removeFromSplit": "Remove from Split",
|
||||
"assignToPane": "Assign to pane"
|
||||
"assignToPane": "Assign to pane",
|
||||
"hotkeysTitle": "Keyboard Shortcuts",
|
||||
"hotkeysSplitRight": "Toggle 2-way split",
|
||||
"hotkeysSplitBelow": "Toggle 3-way split",
|
||||
"hotkeysNavigatePane": "Navigate panes",
|
||||
"hotkeysNextTab": "Next tab",
|
||||
"hotkeysPrevTab": "Previous tab"
|
||||
},
|
||||
"snippets": {
|
||||
"title": "Snippets",
|
||||
@@ -2364,7 +2511,33 @@
|
||||
"shareLoadError": "Failed to load share data",
|
||||
"loading": "Loading...",
|
||||
"close": "Close",
|
||||
"reorderFailed": "Failed to save snippet order"
|
||||
"reorderFailed": "Failed to save snippet order",
|
||||
"importExport": "Import / Export",
|
||||
"exportBtn": "Export JSON",
|
||||
"importBtn": "Import JSON",
|
||||
"exportSuccess": "Snippets exported successfully",
|
||||
"exportFailed": "Failed to export snippets",
|
||||
"importTitle": "Import Snippets",
|
||||
"importDescription": "Import snippets and folders from a JSON file exported by Termix",
|
||||
"importDropOrClick": "Drop a JSON file here or click to browse",
|
||||
"importSelectedFile": "Selected: {{name}}",
|
||||
"importOverwrite": "Overwrite existing snippets with the same name and folder",
|
||||
"importStartBtn": "Import",
|
||||
"importSuccess": "Import complete: {{snippets}} snippet(s) added, {{updated}} updated, {{skipped}} skipped, {{folders}} folder(s) added",
|
||||
"importFailed": "Failed to import snippets",
|
||||
"importInvalidFile": "Invalid file: expected a JSON object with snippets or folders arrays",
|
||||
"targetHostsLabel": "Target Hosts",
|
||||
"targetHostsHint": "Assign hosts to run this snippet directly without an open terminal.",
|
||||
"noHostsAvailable": "No hosts configured",
|
||||
"clearTargetHosts": "Clear all",
|
||||
"hasTargetHosts": "Has target hosts",
|
||||
"runOnTargets": "Run on Targets",
|
||||
"directRunSuccess": "Ran \"{{name}}\" on {{count}} host(s)",
|
||||
"directRunPartialFail": "\"{{name}}\" failed on one or more hosts",
|
||||
"executionResultTitle": "Execution Results: {{name}}",
|
||||
"executionResultDescription": "Output from running the snippet on each target host.",
|
||||
"executionSuccess": "Success",
|
||||
"executionFailed": "Failed"
|
||||
},
|
||||
"userProfile": {
|
||||
"storageModeLocal": "Browser",
|
||||
@@ -2389,6 +2562,7 @@
|
||||
"versionLabel": "Version",
|
||||
"deleteAccount": "Delete Account",
|
||||
"deleteAccountDescription": "Permanently delete your account",
|
||||
"changeServerDescription": "Switch to a different Termix backend server",
|
||||
"deleteButton": "Delete",
|
||||
"deleteAccountPermanent": "This action is permanent and cannot be undone.",
|
||||
"deleteAccountWarning": "All sessions, hosts, credentials, and settings will be permanently deleted.",
|
||||
@@ -2400,6 +2574,8 @@
|
||||
"settingsTerminal": "Terminal",
|
||||
"commandAutocomplete": "Command Autocomplete",
|
||||
"commandAutocompleteDesc": "Show autocomplete while typing",
|
||||
"terminalLinkBehavior": "Terminal Link Click",
|
||||
"terminalLinkBehaviorDesc": "Default behavior when clicking links in the terminal",
|
||||
"historyTracking": "History Tracking",
|
||||
"historyTrackingDesc": "Track terminal commands",
|
||||
"commandPalette": "Command Palette",
|
||||
@@ -2413,6 +2589,10 @@
|
||||
"showHostTagsDesc": "Display tags in host list",
|
||||
"hostTrayOnClick": "Click to Expand Host Actions",
|
||||
"hostTrayOnClickDesc": "Always show connection buttons; click to expand management options instead of hover",
|
||||
"compactHostView": "Compact Host View",
|
||||
"compactHostViewDesc": "Collapse each host to a single line showing only its name and address",
|
||||
"statusColors": "Real Status Colors",
|
||||
"statusColorsDesc": "Use green/red for online/offline status instead of the accent color",
|
||||
"pinAppRail": "Pin App Rail",
|
||||
"pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover",
|
||||
"settingsNavigation": "Navigation",
|
||||
@@ -2581,5 +2761,29 @@
|
||||
"timeMinutes": "{{count}}m ago",
|
||||
"timeHours": "{{count}}h ago",
|
||||
"timeDays": "{{count}}d ago"
|
||||
},
|
||||
"mobileKeyboard": {
|
||||
"shift": "Shift",
|
||||
"ctrl": "Ctrl",
|
||||
"esc": "Esc",
|
||||
"tab": "Tab",
|
||||
"backTab": "⇥",
|
||||
"arrowUp": "Arrow Up",
|
||||
"arrowDown": "Arrow Down",
|
||||
"arrowLeft": "Arrow Left",
|
||||
"arrowRight": "Arrow Right",
|
||||
"home": "Home",
|
||||
"end": "End",
|
||||
"pageUp": "PgUp",
|
||||
"pageDown": "PgDn",
|
||||
"delete": "Del",
|
||||
"editQuickKeys": "Edit quick keys",
|
||||
"quickKeysTitle": "Quick Keys",
|
||||
"quickKeysDesc": "Tap × to remove. Supports up to 8 characters.",
|
||||
"quickKeyPlaceholder": "e.g. sudo ",
|
||||
"addQuickKey": "Add",
|
||||
"removeQuickKey": "Remove",
|
||||
"resetDefaults": "Reset to defaults",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
|
||||
+1937
-1334
File diff suppressed because it is too large
Load Diff
+2478
-1875
File diff suppressed because it is too large
Load Diff
+1966
-1363
File diff suppressed because it is too large
Load Diff
+1979
-1376
File diff suppressed because it is too large
Load Diff
+1913
-1310
File diff suppressed because it is too large
Load Diff
+2452
-1849
File diff suppressed because it is too large
Load Diff
+2369
-1766
File diff suppressed because it is too large
Load Diff
+2365
-1762
File diff suppressed because it is too large
Load Diff
+2470
-1867
File diff suppressed because it is too large
Load Diff
+2403
-1800
File diff suppressed because it is too large
Load Diff
+2480
-1877
File diff suppressed because it is too large
Load Diff
+2420
-1817
File diff suppressed because it is too large
Load Diff
+1964
-1361
File diff suppressed because it is too large
Load Diff
+1970
-1367
File diff suppressed because it is too large
Load Diff
+1939
-1336
File diff suppressed because it is too large
Load Diff
+1925
-1322
File diff suppressed because it is too large
Load Diff
+2394
-1791
File diff suppressed because it is too large
Load Diff
+2428
-1825
File diff suppressed because it is too large
Load Diff
+1965
-1362
File diff suppressed because it is too large
Load Diff
+2411
-1808
File diff suppressed because it is too large
Load Diff
+2459
-1856
File diff suppressed because it is too large
Load Diff
+2444
-1841
File diff suppressed because it is too large
Load Diff
+2366
-1763
File diff suppressed because it is too large
Load Diff
+2458
-1855
File diff suppressed because it is too large
Load Diff
+2437
-1834
File diff suppressed because it is too large
Load Diff
+2478
-1875
File diff suppressed because it is too large
Load Diff
+1971
-1368
File diff suppressed because it is too large
Load Diff
+2425
-1822
File diff suppressed because it is too large
Load Diff
+1976
-1373
File diff suppressed because it is too large
Load Diff
+1943
-1340
File diff suppressed because it is too large
Load Diff
+2461
-1858
File diff suppressed because it is too large
Load Diff
+1943
-1340
File diff suppressed because it is too large
Load Diff
+2416
-1813
File diff suppressed because it is too large
Load Diff
+1946
-1343
File diff suppressed because it is too large
Load Diff
+27
-1
@@ -1501,6 +1501,7 @@ export {
|
||||
updateSSHHost,
|
||||
wakeOnLan,
|
||||
bulkImportSSHHosts,
|
||||
importSSHConfigHosts,
|
||||
discoverProxmoxGuests,
|
||||
bulkUpdateSSHHosts,
|
||||
deleteSSHHost,
|
||||
@@ -1637,6 +1638,21 @@ export async function registerUser(
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminCreateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/users/admin-create", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "admin create user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginUser(
|
||||
username: string,
|
||||
password: string,
|
||||
@@ -1914,6 +1930,8 @@ export {
|
||||
updateRegistrationAllowed,
|
||||
getOidcAutoProvision,
|
||||
updateOidcAutoProvision,
|
||||
getOidcSilentLoginDefault,
|
||||
updateOidcSilentLoginDefault,
|
||||
updatePasswordLoginAllowed,
|
||||
getPasswordResetAllowed,
|
||||
updatePasswordResetAllowed,
|
||||
@@ -1994,12 +2012,20 @@ export {
|
||||
} from "@/api/snippets-api";
|
||||
|
||||
// ============================================================================
|
||||
export type { UptimeInfo, RecentActivityItem } from "@/api/dashboard-api";
|
||||
export type {
|
||||
UptimeInfo,
|
||||
RecentActivityItem,
|
||||
ServiceLink,
|
||||
} from "@/api/dashboard-api";
|
||||
export {
|
||||
getUptime,
|
||||
getRecentActivity,
|
||||
logActivity,
|
||||
resetRecentActivity,
|
||||
getServiceLinks,
|
||||
createServiceLink,
|
||||
deleteServiceLink,
|
||||
updateServiceLink,
|
||||
} from "@/api/dashboard-api";
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+56
-8
@@ -15,6 +15,7 @@ import {
|
||||
LayoutPanelLeft,
|
||||
Plus,
|
||||
Minus,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, TabType, SplitMode } from "@/types/ui-types";
|
||||
@@ -35,6 +36,7 @@ export function TabBar({
|
||||
onSplitTab,
|
||||
onAddToSplit,
|
||||
onRemoveFromSplit,
|
||||
onRenameTab,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
@@ -48,6 +50,7 @@ export function TabBar({
|
||||
onSplitTab: (tabId: string, mode: SplitMode) => void;
|
||||
onAddToSplit: (tabId: string) => void;
|
||||
onRemoveFromSplit: (tabId: string) => void;
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(true);
|
||||
@@ -58,6 +61,9 @@ export function TabBar({
|
||||
const [contextPos, setContextPos] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [renamingTabId, setRenamingTabId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const tabBarRef = useRef<HTMLDivElement>(null);
|
||||
const tabEls = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
@@ -165,6 +171,19 @@ export function TabBar({
|
||||
return () => window.removeEventListener("mousedown", onDown);
|
||||
}, [contextTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (renamingTabId) {
|
||||
setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [renamingTabId]);
|
||||
|
||||
function commitRename() {
|
||||
if (!renamingTabId) return;
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed) onRenameTab?.(renamingTabId, trimmed);
|
||||
setRenamingTabId(null);
|
||||
}
|
||||
|
||||
const dragIdx = tabs.findIndex((t) => t.id === dragTabId);
|
||||
const target = dragTargetIndex ?? dragIdx;
|
||||
|
||||
@@ -288,8 +307,25 @@ export function TabBar({
|
||||
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 size-1 rounded-full bg-muted-foreground/40 z-10" />
|
||||
)}
|
||||
{tabIcon(tab.type)}
|
||||
{tab.type !== "dashboard" && tab.label}
|
||||
{tab.type !== "dashboard" && (
|
||||
{tab.type !== "dashboard" && renamingTabId === tab.id ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
else if (e.key === "Escape") setRenamingTabId(null);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-transparent border-b border-accent-brand outline-none text-sm w-28 min-w-0"
|
||||
style={{ fontWeight: "inherit" }}
|
||||
/>
|
||||
) : (
|
||||
tab.type !== "dashboard" && tab.label
|
||||
)}
|
||||
{tab.type !== "dashboard" && renamingTabId !== tab.id && (
|
||||
<div
|
||||
className={`flex items-center gap-0.5 ml-1 ${active ? "opacity-100" : "opacity-0 group-hover/tab:opacity-100"}`}
|
||||
>
|
||||
@@ -300,7 +336,7 @@ export function TabBar({
|
||||
e.stopPropagation();
|
||||
onRefreshTab(tab.id);
|
||||
}}
|
||||
title="Refresh connection"
|
||||
title={t("nav.refreshTab")}
|
||||
className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
@@ -456,13 +492,25 @@ export function TabBar({
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Refresh connection
|
||||
{t("nav.refreshTab")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
setRenameValue(ctxTab.label);
|
||||
setRenamingTabId(contextTabId);
|
||||
setContextTabId(null);
|
||||
setContextPos(null);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
{t("nav.renameTab")}
|
||||
</button>
|
||||
<div className="h-px bg-border my-1" />
|
||||
{/* Split submenu */}
|
||||
<div className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Split
|
||||
{t("terminal.split.splitTab")}
|
||||
</div>
|
||||
{SPLIT_MODES.filter((m) => m.id !== "none").map((mode) => (
|
||||
<button
|
||||
@@ -489,7 +537,7 @@ export function TabBar({
|
||||
}}
|
||||
>
|
||||
<Minus className="size-3" />
|
||||
Remove from split
|
||||
{t("terminal.split.removeFromSplit")}
|
||||
</button>
|
||||
) : hasEmptySlot ? (
|
||||
<button
|
||||
@@ -500,7 +548,7 @@ export function TabBar({
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
Add to split
|
||||
{t("terminal.split.addToSplit")}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
@@ -514,7 +562,7 @@ export function TabBar({
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
Close tab
|
||||
{t("nav.closeTab")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
+62
-18
@@ -20,6 +20,8 @@ import type {
|
||||
TerminalHandle,
|
||||
TerminalHostConfig,
|
||||
} from "@/features/terminal/Terminal";
|
||||
import { MobileTerminalKeyboard } from "@/features/terminal/MobileTerminalKeyboard";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { FileManager } from "@/features/file-manager/FileManager";
|
||||
import { DockerManager } from "@/features/docker/DockerManager";
|
||||
import { HostMetricsTab } from "@/features/host-metrics/HostMetricsTab";
|
||||
@@ -127,33 +129,58 @@ function TerminalTabContent({
|
||||
label,
|
||||
isVisible,
|
||||
onCloseTab,
|
||||
onRenameTab,
|
||||
onOpenFileInEditor,
|
||||
onOpenFileManager,
|
||||
}: {
|
||||
tab: Tab;
|
||||
host: Host;
|
||||
label: string;
|
||||
isVisible: boolean;
|
||||
onCloseTab?: (id: string) => void;
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||
onOpenFileInEditor?: (filePath: string) => void;
|
||||
onOpenFileManager?: (path?: string) => void;
|
||||
}) {
|
||||
const { previewTerminalTheme } = useTabsSafe();
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<CommandHistoryProvider>
|
||||
<TerminalFeature
|
||||
ref={tab.terminalRef as React.Ref<TerminalHandle>}
|
||||
hostConfig={
|
||||
{
|
||||
...hostToSSHHost(host),
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
instanceId: tab.instanceId ?? tab.id,
|
||||
restoredSessionId: tab.restoredSessionId ?? null,
|
||||
} as TerminalHostConfig
|
||||
}
|
||||
isVisible={isVisible}
|
||||
title={label}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
onClose={() => onCloseTab?.(tab.id)}
|
||||
previewTheme={previewTerminalTheme}
|
||||
/>
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalFeature
|
||||
ref={tab.terminalRef as React.Ref<TerminalHandle>}
|
||||
hostConfig={
|
||||
{
|
||||
...hostToSSHHost(host),
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
instanceId: tab.instanceId ?? tab.id,
|
||||
restoredSessionId: tab.restoredSessionId ?? null,
|
||||
} as TerminalHostConfig
|
||||
}
|
||||
isVisible={isVisible}
|
||||
title={label}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
onClose={() => onCloseTab?.(tab.id)}
|
||||
onTitleChange={
|
||||
onRenameTab && host.terminalConfig?.useSSHTitle
|
||||
? (title) => onRenameTab(tab.id, title)
|
||||
: undefined
|
||||
}
|
||||
previewTheme={previewTerminalTheme}
|
||||
onOpenFileInEditor={onOpenFileInEditor}
|
||||
onOpenFileManager={onOpenFileManager}
|
||||
/>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<MobileTerminalKeyboard
|
||||
terminalRef={
|
||||
tab.terminalRef as React.RefObject<TerminalHandle | null>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandHistoryProvider>
|
||||
);
|
||||
}
|
||||
@@ -164,6 +191,9 @@ export function renderTabContent(
|
||||
onOpenTab?: (host: Host, type: TabType) => void,
|
||||
onCloseTab?: (id: string) => void,
|
||||
isVisible = true,
|
||||
onOpenFileInEditor?: (host: Host, filePath: string) => void,
|
||||
onOpenFileManager?: (host: Host, path?: string) => void,
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void,
|
||||
) {
|
||||
const { host, label } = tab;
|
||||
|
||||
@@ -191,6 +221,15 @@ export function renderTabContent(
|
||||
label={label}
|
||||
isVisible={isVisible}
|
||||
onCloseTab={onCloseTab}
|
||||
onRenameTab={onRenameTab}
|
||||
onOpenFileInEditor={
|
||||
onOpenFileInEditor
|
||||
? (fp) => onOpenFileInEditor(host, fp)
|
||||
: undefined
|
||||
}
|
||||
onOpenFileManager={
|
||||
onOpenFileManager ? (p) => onOpenFileManager(host, p) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -202,7 +241,12 @@ export function renderTabContent(
|
||||
messageKey="fileManager.noHostSelected"
|
||||
/>
|
||||
);
|
||||
return <FileManager initialHost={hostToSSHHost(host)} />;
|
||||
return (
|
||||
<FileManager
|
||||
initialHost={hostToSSHHost(host)}
|
||||
initialFilePath={tab.initialFilePath}
|
||||
/>
|
||||
);
|
||||
|
||||
case "docker":
|
||||
if (!host)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
RefreshCw,
|
||||
Share2,
|
||||
Trash2,
|
||||
Unlink,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -68,6 +69,10 @@ type UsersSectionProps = {
|
||||
SetStateAction<{ id: string; username: string; isOidc: boolean } | null>
|
||||
>;
|
||||
setLinkAccountOpen: Dispatch<SetStateAction<boolean>>;
|
||||
setUnlinkAccountTarget: Dispatch<
|
||||
SetStateAction<{ id: string; username: string } | null>
|
||||
>;
|
||||
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export function AdminUsersSection({
|
||||
@@ -81,6 +86,8 @@ export function AdminUsersSection({
|
||||
setEditUserOpen,
|
||||
setLinkAccountTarget,
|
||||
setLinkAccountOpen,
|
||||
setUnlinkAccountTarget,
|
||||
setUnlinkAccountOpen,
|
||||
}: UsersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -160,11 +167,28 @@ export function AdminUsersSection({
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
{!(user.isOidc && user.passwordHash) && (
|
||||
{user.isOidc && user.passwordHash ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
title={t("admin.unlinkAccount")}
|
||||
onClick={() => {
|
||||
setUnlinkAccountTarget({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
setUnlinkAccountOpen(true);
|
||||
}}
|
||||
>
|
||||
<Unlink className="size-3" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
title={t("admin.linkAccountTitle")}
|
||||
onClick={() => {
|
||||
setLinkAccountTarget({
|
||||
id: user.id,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
deleteUser,
|
||||
revokeAllUserSessions,
|
||||
createRole,
|
||||
registerUser,
|
||||
adminCreateUser,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
getRegistrationAllowed,
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
updateGuacamoleSettings,
|
||||
getOidcAutoProvision,
|
||||
updateOidcAutoProvision,
|
||||
getOidcSilentLoginDefault,
|
||||
updateOidcSilentLoginDefault,
|
||||
getCommandHistoryEnabled,
|
||||
updateCommandHistoryEnabled,
|
||||
isElectron,
|
||||
@@ -36,7 +38,16 @@ import {
|
||||
import {
|
||||
getTailscaleSettings,
|
||||
updateTailscaleSettings,
|
||||
getHostDefaults,
|
||||
updateHostDefaults,
|
||||
type HostDefaults,
|
||||
} from "@/api/settings-api";
|
||||
import {
|
||||
getAcmeSslSettings,
|
||||
updateAcmeSslSettings,
|
||||
requestAcmeCertificate,
|
||||
type AcmeSettings,
|
||||
} from "@/api/acme-ssl-api";
|
||||
import {
|
||||
getAdminSSOProviders,
|
||||
updateSSOProvider,
|
||||
@@ -58,7 +69,9 @@ import { getBasePath } from "@/lib/base-path";
|
||||
import {
|
||||
AdminDatabaseSection,
|
||||
AdminGeneralSettingsSection,
|
||||
AdminHostDefaultsSection,
|
||||
AdminSSOSection,
|
||||
AdminSSLSection,
|
||||
} from "./AdminSettingsSections";
|
||||
import { SSOProviderDialog } from "./SSOProviderDialog";
|
||||
import { AdminApiKeysSection } from "./AdminApiKeysSection";
|
||||
@@ -67,6 +80,7 @@ import {
|
||||
AdminCreateUserDialog,
|
||||
AdminEditUserDialog,
|
||||
AdminLinkAccountDialog,
|
||||
AdminUnlinkAccountDialog,
|
||||
} from "./AdminUserDialogs";
|
||||
|
||||
type ApiErrorLike = {
|
||||
@@ -97,9 +111,11 @@ export function AdminSettingsPanel() {
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||
|
||||
// SSO / auto-provision state
|
||||
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
|
||||
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
|
||||
const [ssoDialogOpen, setSsoDialogOpen] = useState(false);
|
||||
const [ssoDialogProvider, setSsoDialogProvider] =
|
||||
@@ -127,6 +143,13 @@ export function AdminSettingsPanel() {
|
||||
isOidc: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Unlink account dialog
|
||||
const [unlinkAccountOpen, setUnlinkAccountOpen] = useState(false);
|
||||
const [unlinkAccountTarget, setUnlinkAccountTarget] = useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
|
||||
// Create role form
|
||||
const [showCreateRole, setShowCreateRole] = useState(false);
|
||||
const [newRoleName, setNewRoleName] = useState("");
|
||||
@@ -147,6 +170,22 @@ export function AdminSettingsPanel() {
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
|
||||
// ACME SSL state
|
||||
const defaultAcmeSettings: AcmeSettings = {
|
||||
enabled: false,
|
||||
domain: "",
|
||||
email: "",
|
||||
challengeType: "http-webroot",
|
||||
cloudflareToken: "",
|
||||
lastIssuedAt: null,
|
||||
certStatus: "none",
|
||||
certExpiresAt: null,
|
||||
};
|
||||
const [acmeSettings, setAcmeSettings] =
|
||||
useState<AcmeSettings>(defaultAcmeSettings);
|
||||
const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState("");
|
||||
const [acmeRequesting, setAcmeRequesting] = useState(false);
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
@@ -217,6 +256,7 @@ export function AdminSettingsPanel() {
|
||||
level,
|
||||
guac,
|
||||
oidcProv,
|
||||
oidcSilent,
|
||||
tailscale,
|
||||
cmdHistory,
|
||||
] = await Promise.allSettled([
|
||||
@@ -228,6 +268,7 @@ export function AdminSettingsPanel() {
|
||||
getLogLevel(),
|
||||
getGuacamoleSettings(),
|
||||
getOidcAutoProvision(),
|
||||
getOidcSilentLoginDefault(),
|
||||
getTailscaleSettings(),
|
||||
getCommandHistoryEnabled(),
|
||||
]);
|
||||
@@ -237,6 +278,8 @@ export function AdminSettingsPanel() {
|
||||
setAllowPasswordLogin(pwLogin.value.allowed);
|
||||
if (oidcProv.status === "fulfilled")
|
||||
setOidcAutoProvision(oidcProv.value.enabled);
|
||||
if (oidcSilent.status === "fulfilled")
|
||||
setOidcSilentLoginDefault(oidcSilent.value.enabled);
|
||||
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
|
||||
if (timeout.status === "fulfilled")
|
||||
setSessionTimeout(String(timeout.value.timeoutHours));
|
||||
@@ -258,6 +301,14 @@ export function AdminSettingsPanel() {
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
getHostDefaults()
|
||||
.then((d) => setHostDefaults(d))
|
||||
.catch(() => {});
|
||||
|
||||
getAcmeSslSettings()
|
||||
.then((s) => setAcmeSettings(s))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function loadSSOProviders() {
|
||||
@@ -273,6 +324,15 @@ export function AdminSettingsPanel() {
|
||||
setOpenSection((prev) => (prev === id ? null : id));
|
||||
}
|
||||
|
||||
async function handleSaveHostDefaults() {
|
||||
try {
|
||||
await updateHostDefaults(hostDefaults);
|
||||
toast.success(t("admin.hostDefaultsSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.hostDefaultsSaveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRegistration() {
|
||||
const newVal = !allowRegistration;
|
||||
setAllowRegistration(newVal);
|
||||
@@ -289,9 +349,10 @@ export function AdminSettingsPanel() {
|
||||
setAllowPasswordLogin(newVal);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(newVal);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
setAllowPasswordLogin(!newVal);
|
||||
toast.error(t("admin.updatePasswordLoginFailed"));
|
||||
const msg = (e as ApiErrorLike).response?.data?.error;
|
||||
toast.error(msg || t("admin.updatePasswordLoginFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +367,17 @@ export function AdminSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleOidcSilentLoginDefault() {
|
||||
const newVal = !oidcSilentLoginDefault;
|
||||
setOidcSilentLoginDefault(newVal);
|
||||
try {
|
||||
await updateOidcSilentLoginDefault(newVal);
|
||||
} catch {
|
||||
setOidcSilentLoginDefault(!newVal);
|
||||
toast.error(t("admin.updateOidcSilentLoginDefaultFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePasswordReset() {
|
||||
const newVal = !allowPasswordReset;
|
||||
setAllowPasswordReset(newVal);
|
||||
@@ -435,6 +507,50 @@ export function AdminSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAcmeSettings() {
|
||||
try {
|
||||
const payload: Parameters<typeof updateAcmeSslSettings>[0] = {
|
||||
enabled: acmeSettings.enabled,
|
||||
domain: acmeSettings.domain,
|
||||
email: acmeSettings.email,
|
||||
challengeType: acmeSettings.challengeType,
|
||||
...(cloudflareTokenDraft && { cloudflareToken: cloudflareTokenDraft }),
|
||||
};
|
||||
const updated = await updateAcmeSslSettings(payload);
|
||||
setAcmeSettings(updated);
|
||||
setCloudflareTokenDraft("");
|
||||
toast.success(t("admin.sslSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.sslSaveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequestAcmeCertificate() {
|
||||
if (!acmeSettings.domain || !acmeSettings.email) {
|
||||
toast.error(t("admin.sslRequiresDomain"));
|
||||
return;
|
||||
}
|
||||
setAcmeRequesting(true);
|
||||
try {
|
||||
if (cloudflareTokenDraft) {
|
||||
await updateAcmeSslSettings({
|
||||
domain: acmeSettings.domain,
|
||||
email: acmeSettings.email,
|
||||
challengeType: acmeSettings.challengeType,
|
||||
cloudflareToken: cloudflareTokenDraft,
|
||||
});
|
||||
setCloudflareTokenDraft("");
|
||||
}
|
||||
const result = await requestAcmeCertificate();
|
||||
setAcmeSettings(result);
|
||||
toast.success(t("admin.sslRequestCertSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("admin.sslRequestCertFailed")));
|
||||
} finally {
|
||||
setAcmeRequesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderSaved(saved: SSOProvider) {
|
||||
setSsoProviders((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === saved.id);
|
||||
@@ -458,7 +574,7 @@ export function AdminSettingsPanel() {
|
||||
}
|
||||
setCreateUserLoading(true);
|
||||
try {
|
||||
await registerUser(newUsername.trim(), newPassword);
|
||||
await adminCreateUser(newUsername.trim(), newPassword);
|
||||
toast.success(t("admin.createUserSuccess", { username: newUsername }));
|
||||
setCreateUserOpen(false);
|
||||
setNewUsername("");
|
||||
@@ -699,6 +815,8 @@ export function AdminSettingsPanel() {
|
||||
handleTogglePasswordLogin={handleTogglePasswordLogin}
|
||||
oidcAutoProvision={oidcAutoProvision}
|
||||
handleToggleOidcAutoProvision={handleToggleOidcAutoProvision}
|
||||
oidcSilentLoginDefault={oidcSilentLoginDefault}
|
||||
handleToggleOidcSilentLoginDefault={handleToggleOidcSilentLoginDefault}
|
||||
allowPasswordReset={allowPasswordReset}
|
||||
handleTogglePasswordReset={handleTogglePasswordReset}
|
||||
commandHistoryEnabled={commandHistoryEnabled}
|
||||
@@ -751,6 +869,8 @@ export function AdminSettingsPanel() {
|
||||
setEditUserOpen={setEditUserOpen}
|
||||
setLinkAccountTarget={setLinkAccountTarget}
|
||||
setLinkAccountOpen={setLinkAccountOpen}
|
||||
setUnlinkAccountTarget={setUnlinkAccountTarget}
|
||||
setUnlinkAccountOpen={setUnlinkAccountOpen}
|
||||
/>
|
||||
|
||||
<AdminSessionsSection
|
||||
@@ -778,6 +898,14 @@ export function AdminSettingsPanel() {
|
||||
createRoleLoading={createRoleLoading}
|
||||
/>
|
||||
|
||||
<AdminHostDefaultsSection
|
||||
open={openSection === "host-defaults"}
|
||||
onToggle={() => toggle("host-defaults")}
|
||||
defaults={hostDefaults}
|
||||
setDefaults={setHostDefaults}
|
||||
handleSaveDefaults={handleSaveHostDefaults}
|
||||
/>
|
||||
|
||||
<AdminDatabaseSection
|
||||
open={openSection === "database"}
|
||||
onToggle={() => toggle("database")}
|
||||
@@ -789,6 +917,18 @@ export function AdminSettingsPanel() {
|
||||
handleImportDatabase={handleImportDatabase}
|
||||
/>
|
||||
|
||||
<AdminSSLSection
|
||||
open={openSection === "ssl"}
|
||||
onToggle={() => toggle("ssl")}
|
||||
settings={acmeSettings}
|
||||
setSettings={setAcmeSettings}
|
||||
cloudflareTokenDraft={cloudflareTokenDraft}
|
||||
setCloudflareTokenDraft={setCloudflareTokenDraft}
|
||||
requesting={acmeRequesting}
|
||||
handleSave={handleSaveAcmeSettings}
|
||||
handleRequest={handleRequestAcmeCertificate}
|
||||
/>
|
||||
|
||||
<AdminApiKeysSection
|
||||
open={openSection === "api-keys"}
|
||||
onToggle={() => toggle("api-keys")}
|
||||
@@ -850,6 +990,21 @@ export function AdminSettingsPanel() {
|
||||
setUsers={setUsers}
|
||||
users={users}
|
||||
/>
|
||||
|
||||
<AdminUnlinkAccountDialog
|
||||
open={unlinkAccountOpen}
|
||||
onOpenChange={setUnlinkAccountOpen}
|
||||
unlinkAccountTarget={unlinkAccountTarget}
|
||||
onSuccess={(userId) =>
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === userId
|
||||
? { ...u, isOidc: false, passwordHash: undefined }
|
||||
: u,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,18 +2,30 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import {
|
||||
Database,
|
||||
Lock,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||
import type { SSOProvider, SSOProviderType } from "@/types/index";
|
||||
import type { HostDefaults } from "@/api/settings-api";
|
||||
import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
|
||||
|
||||
type GeneralSettingsSectionProps = {
|
||||
open: boolean;
|
||||
@@ -24,6 +36,8 @@ type GeneralSettingsSectionProps = {
|
||||
handleTogglePasswordLogin: () => void;
|
||||
oidcAutoProvision: boolean;
|
||||
handleToggleOidcAutoProvision: () => void;
|
||||
oidcSilentLoginDefault: boolean;
|
||||
handleToggleOidcSilentLoginDefault: () => void;
|
||||
allowPasswordReset: boolean;
|
||||
handleTogglePasswordReset: () => void;
|
||||
commandHistoryEnabled: boolean;
|
||||
@@ -57,6 +71,8 @@ export function AdminGeneralSettingsSection({
|
||||
handleTogglePasswordLogin,
|
||||
oidcAutoProvision,
|
||||
handleToggleOidcAutoProvision,
|
||||
oidcSilentLoginDefault,
|
||||
handleToggleOidcSilentLoginDefault,
|
||||
allowPasswordReset,
|
||||
handleTogglePasswordReset,
|
||||
commandHistoryEnabled,
|
||||
@@ -117,6 +133,15 @@ export function AdminGeneralSettingsSection({
|
||||
onToggle={handleToggleOidcAutoProvision}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.oidcSilentLoginDefault")}
|
||||
description={t("admin.oidcSilentLoginDefaultDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={oidcSilentLoginDefault}
|
||||
onToggle={handleToggleOidcSilentLoginDefault}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.allowPasswordReset")}
|
||||
description={t("admin.allowPasswordResetDesc")}
|
||||
@@ -774,3 +799,388 @@ export function AdminSSOSection({
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
type AdminHostDefaultsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
defaults: HostDefaults;
|
||||
setDefaults: Dispatch<SetStateAction<HostDefaults>>;
|
||||
handleSaveDefaults: () => void;
|
||||
};
|
||||
|
||||
export function AdminHostDefaultsSection({
|
||||
open,
|
||||
onToggle,
|
||||
defaults,
|
||||
setDefaults,
|
||||
handleSaveDefaults,
|
||||
}: AdminHostDefaultsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionHostDefaults")}
|
||||
icon={<Server className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.hostDefaultsDesc")}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.hostDefaultsSocks5")}
|
||||
</span>
|
||||
<SettingRow
|
||||
label={t("admin.hostDefaultsUseSocks5")}
|
||||
description={t("admin.hostDefaultsUseSocks5Desc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={defaults.useSocks5 ?? false}
|
||||
onToggle={() =>
|
||||
setDefaults((p) => ({ ...p, useSocks5: !p.useSocks5 }))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{defaults.useSocks5 && (
|
||||
<div className="flex flex-col gap-3 ml-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.hostDefaultsSocks5Host")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={defaults.socks5Host ?? ""}
|
||||
onChange={(e) =>
|
||||
setDefaults((p) => ({ ...p, socks5Host: e.target.value }))
|
||||
}
|
||||
placeholder="127.0.0.1"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaults.socks5Port ?? 1080}
|
||||
onChange={(e) =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
socks5Port: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
placeholder="1080"
|
||||
className="text-xs w-24 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.hostDefaultsSocks5Username")}
|
||||
</label>
|
||||
<Input
|
||||
value={defaults.socks5Username ?? ""}
|
||||
onChange={(e) =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
socks5Username: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.hostDefaultsSocks5Password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
value={defaults.socks5Password ?? ""}
|
||||
onChange={(e) =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
socks5Password: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 text-xs pr-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.hostDefaultsMetrics")}
|
||||
</span>
|
||||
<SettingRow
|
||||
label={t("admin.hostDefaultsMetricsEnabled")}
|
||||
description={t("admin.hostDefaultsMetricsEnabledDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={defaults.metricsEnabled ?? true}
|
||||
onToggle={() =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
metricsEnabled: !(p.metricsEnabled ?? true),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.hostDefaultsStatusCheckEnabled")}
|
||||
description={t("admin.hostDefaultsStatusCheckEnabledDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={defaults.statusCheckEnabled ?? true}
|
||||
onToggle={() =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
statusCheckEnabled: !(p.statusCheckEnabled ?? true),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.hostDefaultsTerminal")}
|
||||
</span>
|
||||
<SettingRow
|
||||
label={t("admin.hostDefaultsSessionLogging")}
|
||||
description={t("admin.hostDefaultsSessionLoggingDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={defaults.enableSessionLogging ?? true}
|
||||
onToggle={() =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
enableSessionLogging: !(p.enableSessionLogging ?? true),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.hostDefaultsCommandHistory")}
|
||||
description={t("admin.hostDefaultsCommandHistoryDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={defaults.enableCommandHistory ?? true}
|
||||
onToggle={() =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
enableCommandHistory: !(p.enableCommandHistory ?? true),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSaveDefaults}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
const CERT_STATUS_STYLES: Record<AcmeSettings["certStatus"], string> = {
|
||||
none: "text-muted-foreground",
|
||||
valid: "text-green-500",
|
||||
expiring: "text-yellow-500",
|
||||
expired: "text-destructive",
|
||||
};
|
||||
|
||||
type AdminSSLSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
settings: AcmeSettings;
|
||||
setSettings: Dispatch<SetStateAction<AcmeSettings>>;
|
||||
cloudflareTokenDraft: string;
|
||||
setCloudflareTokenDraft: Dispatch<SetStateAction<string>>;
|
||||
requesting: boolean;
|
||||
handleSave: () => void;
|
||||
handleRequest: () => void;
|
||||
};
|
||||
|
||||
export function AdminSSLSection({
|
||||
open,
|
||||
onToggle,
|
||||
settings,
|
||||
setSettings,
|
||||
cloudflareTokenDraft,
|
||||
setCloudflareTokenDraft,
|
||||
requesting,
|
||||
handleSave,
|
||||
handleRequest,
|
||||
}: AdminSSLSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const certStatusLabel: Record<AcmeSettings["certStatus"], string> = {
|
||||
none: t("admin.sslCertStatusNone"),
|
||||
valid: t("admin.sslCertStatusValid"),
|
||||
expiring: t("admin.sslCertStatusExpiring"),
|
||||
expired: t("admin.sslCertStatusExpired"),
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionSsl")}
|
||||
icon={<Lock className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslDescription")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/networking/ssl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.sslDocsLink")}
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-0.5 p-2 border border-border bg-background/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslCertStatus")}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium ${CERT_STATUS_STYLES[settings.certStatus]}`}
|
||||
>
|
||||
{certStatusLabel[settings.certStatus]}
|
||||
</span>
|
||||
</div>
|
||||
{settings.certExpiresAt && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslCertExpiresAt", {
|
||||
date: new Date(settings.certExpiresAt).toLocaleDateString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{settings.lastIssuedAt && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslLastIssued", {
|
||||
date: new Date(settings.lastIssuedAt).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslDomain")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.domain}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, domain: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslDomainPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslEmail")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.email}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, email: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslEmailPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslChallengeType")}
|
||||
</label>
|
||||
<Select
|
||||
value={settings.challengeType}
|
||||
onValueChange={(v) =>
|
||||
setSettings((p) => ({
|
||||
...p,
|
||||
challengeType: v as AcmeChallengeType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http-webroot" className="text-xs">
|
||||
HTTP (webroot)
|
||||
</SelectItem>
|
||||
<SelectItem value="dns-cloudflare" className="text-xs">
|
||||
DNS (Cloudflare)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslChallengeTypeDesc")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{settings.challengeType === "dns-cloudflare" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslCloudflareToken")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
value={cloudflareTokenDraft}
|
||||
onChange={(e) => setCloudflareTokenDraft(e.target.value)}
|
||||
placeholder={
|
||||
settings.cloudflareToken ||
|
||||
t("admin.sslCloudflareTokenPlaceholder")
|
||||
}
|
||||
className="text-xs h-8 pr-8"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslCloudflareTokenDesc")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
|
||||
{t("admin.sslInfoNote")}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("admin.sslSave")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleRequest}
|
||||
disabled={requesting}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${requesting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{requesting
|
||||
? t("admin.sslRequestCertLoading")
|
||||
: t("admin.sslRequestCert")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
assignRoleToUser,
|
||||
linkOIDCToPasswordAccount,
|
||||
removeRoleFromUser,
|
||||
unlinkOIDCFromPasswordAccount,
|
||||
} from "@/main-axios";
|
||||
import type { Role, UserRole } from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
@@ -391,6 +392,80 @@ export function AdminEditUserDialog({
|
||||
);
|
||||
}
|
||||
|
||||
type UnlinkAccountDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: Dispatch<SetStateAction<boolean>>;
|
||||
unlinkAccountTarget: { id: string; username: string } | null;
|
||||
onSuccess: (userId: string) => void;
|
||||
};
|
||||
|
||||
export function AdminUnlinkAccountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
unlinkAccountTarget,
|
||||
onSuccess,
|
||||
}: UnlinkAccountDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!unlinkAccountTarget) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await unlinkOIDCFromPasswordAccount(unlinkAccountTarget.id);
|
||||
toast.success(t("admin.unlinkAccountSuccess"));
|
||||
onSuccess(unlinkAccountTarget.id);
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
toast.error(apiErrorMessage(error, t("admin.unlinkAccountFailed")));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("admin.unlinkAccountTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("admin.unlinkAccountDesc", {
|
||||
username: unlinkAccountTarget?.username,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-destructive">
|
||||
{t("admin.unlinkAccountWarning")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={submitting || !unlinkAccountTarget}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting
|
||||
? t("admin.unlinkAccountInProgress")
|
||||
: t("admin.unlinkAccount")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type LinkAccountDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: Dispatch<SetStateAction<boolean>>;
|
||||
|
||||
+41
-50
@@ -5,6 +5,7 @@ import {
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
LogOut,
|
||||
Network,
|
||||
Play,
|
||||
Plug,
|
||||
@@ -14,12 +15,6 @@ import {
|
||||
User,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
|
||||
export type RailView =
|
||||
@@ -131,8 +126,6 @@ export function AppRail({
|
||||
splitMode,
|
||||
username,
|
||||
isAdmin,
|
||||
profileDropdownOpen,
|
||||
onProfileDropdownChange,
|
||||
onRailClick,
|
||||
onOpenTab,
|
||||
onLogout,
|
||||
@@ -142,8 +135,6 @@ export function AppRail({
|
||||
splitMode: SplitMode;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
profileDropdownOpen: boolean;
|
||||
onProfileDropdownChange: (open: boolean) => void;
|
||||
onRailClick: (view: RailView) => void;
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
onLogout: () => void;
|
||||
@@ -182,7 +173,7 @@ export function AppRail({
|
||||
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || hovered || profileDropdownOpen;
|
||||
const railExpanded = pinned || hovered;
|
||||
const railButtons = buildRailButtons(splitMode, t, hiddenTabs);
|
||||
|
||||
return (
|
||||
@@ -293,50 +284,50 @@ export function AppRail({
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="mx-2 my-1 border-t border-border" />
|
||||
<button
|
||||
onClick={onLogout}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0"}`}
|
||||
>
|
||||
{t("common.logout")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<DropdownMenu
|
||||
open={profileDropdownOpen}
|
||||
onOpenChange={onProfileDropdownChange}
|
||||
<button
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
style={{ padding: "0 6px" }}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
style={{ padding: "0 6px" }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||
style={{ width: 24, height: 24, fontSize: 11 }}
|
||||
>
|
||||
{username.charAt(0).toUpperCase() || "U"}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
||||
{username || "User"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={1}
|
||||
className="!w-auto min-w-max [clip-path:inset(-4px_-4px_-4px_0px)]"
|
||||
<div
|
||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||
style={{ width: 24, height: 24, fontSize: 11 }}
|
||||
>
|
||||
<DropdownMenuItem variant="destructive" onClick={onLogout}>
|
||||
<KeyRound size={14} />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{username.charAt(0).toUpperCase() || "U"}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
||||
{username || "User"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Plug, Search, X } from "lucide-react";
|
||||
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
|
||||
import {
|
||||
getActiveSessions,
|
||||
deleteOpenTab,
|
||||
@@ -62,36 +62,65 @@ function ConnectionRow({
|
||||
isLive,
|
||||
tabType,
|
||||
name,
|
||||
hostName,
|
||||
subLabel,
|
||||
icon,
|
||||
onSwitch,
|
||||
onClose,
|
||||
switchTitle,
|
||||
faded,
|
||||
onRename,
|
||||
isDragging,
|
||||
}: {
|
||||
isActive?: boolean;
|
||||
isLive: boolean;
|
||||
tabType: string;
|
||||
name: string;
|
||||
hostName?: string;
|
||||
subLabel: string;
|
||||
icon: React.ReactNode;
|
||||
onSwitch?: () => void;
|
||||
onClose: () => void;
|
||||
switchTitle?: string;
|
||||
faded?: boolean;
|
||||
onRename?: (newLabel: string) => void;
|
||||
isDragging?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(name);
|
||||
|
||||
function startEdit(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setEditValue(name);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== name && onRename) {
|
||||
onRename(trimmed);
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") commitEdit();
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={onSwitch ? "button" : undefined}
|
||||
tabIndex={onSwitch ? 0 : undefined}
|
||||
onClick={onSwitch}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSwitch?.()}
|
||||
role={onSwitch && !editing ? "button" : undefined}
|
||||
tabIndex={onSwitch && !editing ? 0 : undefined}
|
||||
onClick={!editing ? onSwitch : undefined}
|
||||
onKeyDown={(e) => !editing && e.key === "Enter" && onSwitch?.()}
|
||||
className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${
|
||||
faded ? "opacity-60" : ""
|
||||
} ${
|
||||
} ${isDragging ? "opacity-30" : ""} ${
|
||||
isActive
|
||||
? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand"
|
||||
: onSwitch
|
||||
: onSwitch && !editing
|
||||
? "hover:bg-muted/40 cursor-pointer"
|
||||
: ""
|
||||
}`}
|
||||
@@ -113,13 +142,26 @@ function ConnectionRow({
|
||||
isLive ? "bg-green-500" : "bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-semibold truncate flex-1 ${
|
||||
isActive ? "text-accent-brand" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{editing ? (
|
||||
<input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="text-xs font-semibold flex-1 min-w-0 bg-transparent border-b border-accent-brand outline-none text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`text-xs font-semibold truncate flex-1 ${
|
||||
isActive ? "text-accent-brand" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-4 font-mono shrink-0 text-muted-foreground/60 border-border/60"
|
||||
@@ -128,13 +170,46 @@ function ConnectionRow({
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
|
||||
{hostName && hostName !== name ? (
|
||||
<span className="text-muted-foreground/50">
|
||||
{hostName} ·{" "}
|
||||
</span>
|
||||
) : null}
|
||||
{subLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{switchTitle && onSwitch && (
|
||||
{editing ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
commitEdit();
|
||||
}}
|
||||
className="size-6 flex items-center justify-center text-accent-brand hover:bg-muted/60 rounded transition-colors"
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</button>
|
||||
) : (
|
||||
onRename && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={startEdit}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("connections.rename")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
{switchTitle && onSwitch && !editing && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -150,15 +225,17 @@ function ConnectionRow({
|
||||
<TooltipContent side="left">{switchTitle}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
@@ -187,6 +264,8 @@ export function ConnectionsPanel({
|
||||
onCloseTab,
|
||||
onReopenTab,
|
||||
onForgetBackground,
|
||||
onRenameTab,
|
||||
onReorderTabs,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
@@ -199,6 +278,8 @@ export function ConnectionsPanel({
|
||||
restoredSessionId: string | null,
|
||||
) => void;
|
||||
onForgetBackground: (recordId: string) => void;
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||
onReorderTabs?: (tabs: Tab[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
@@ -206,11 +287,17 @@ export function ConnectionsPanel({
|
||||
const [search, setSearch] = useState("");
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Drag-to-reorder state
|
||||
const [dragTabId, setDragTabId] = useState<string | null>(null);
|
||||
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
|
||||
const rowEls = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const dragStartY = useRef<number>(0);
|
||||
const didDragRef = useRef(false);
|
||||
|
||||
const openTabs = tabs.filter((tab) =>
|
||||
CONNECTION_TAB_TYPES.includes(tab.type),
|
||||
);
|
||||
|
||||
// Filter background records to only those not already open in the tab bar
|
||||
const openInstanceIds = new Set(
|
||||
tabs.map((t) => t.instanceId).filter(Boolean),
|
||||
);
|
||||
@@ -220,9 +307,13 @@ export function ConnectionsPanel({
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const filteredOpenTabs = q
|
||||
? openTabs.filter((tab) =>
|
||||
(tab.host?.name ?? tab.label).toLowerCase().includes(q),
|
||||
)
|
||||
? openTabs.filter((tab) => {
|
||||
const displayName = tab.customLabel ?? tab.host?.name ?? tab.label;
|
||||
return (
|
||||
displayName.toLowerCase().includes(q) ||
|
||||
(tab.host?.name ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
: openTabs;
|
||||
const filteredBackgroundTabs = q
|
||||
? backgroundTabs.filter((r) => {
|
||||
@@ -257,6 +348,69 @@ export function ConnectionsPanel({
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
// Global pointer listeners for drag reorder
|
||||
useEffect(() => {
|
||||
if (!dragTabId) return;
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (Math.abs(e.clientY - dragStartY.current) > 4)
|
||||
didDragRef.current = true;
|
||||
if (!didDragRef.current) return;
|
||||
|
||||
// Find which row the pointer is over
|
||||
let overTabId: string | null = null;
|
||||
rowEls.current.forEach((el, id) => {
|
||||
if (id === dragTabId) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
||||
overTabId = id;
|
||||
}
|
||||
});
|
||||
setDragOverTabId(overTabId);
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (didDragRef.current && dragTabId && onReorderTabs) {
|
||||
// Find drop target
|
||||
let targetTabId: string | null = null;
|
||||
rowEls.current.forEach((el, id) => {
|
||||
if (id === dragTabId) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
||||
targetTabId = id;
|
||||
}
|
||||
});
|
||||
|
||||
if (targetTabId) {
|
||||
const fromIdx = openTabs.findIndex((t) => t.id === dragTabId);
|
||||
const toIdx = openTabs.findIndex((t) => t.id === targetTabId);
|
||||
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
|
||||
const reordered = [...openTabs];
|
||||
reordered.splice(toIdx, 0, reordered.splice(fromIdx, 1)[0]);
|
||||
const connectionSet = new Set(CONNECTION_TAB_TYPES as string[]);
|
||||
const nonConnectionTabs = tabs.filter(
|
||||
(t) => !connectionSet.has(t.type),
|
||||
);
|
||||
onReorderTabs([...nonConnectionTabs, ...reordered]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDragTabId(null);
|
||||
setDragOverTabId(null);
|
||||
setTimeout(() => {
|
||||
didDragRef.current = false;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
};
|
||||
}, [dragTabId, openTabs, tabs, onReorderTabs]);
|
||||
|
||||
const sessionByInstanceId = new Map(
|
||||
activeSessions.map((s) => [s.tabInstanceId, s]),
|
||||
);
|
||||
@@ -322,24 +476,59 @@ export function ConnectionsPanel({
|
||||
? formatDuration(now - liveSession.createdAt)
|
||||
: formatDuration(now - tab.openedAt);
|
||||
|
||||
const displayName = tab.customLabel ?? tab.host?.name ?? tab.label;
|
||||
const hostName = tab.host?.name;
|
||||
const isDraggingThis = dragTabId === tab.id;
|
||||
const isDropTarget = dragOverTabId === tab.id && !isDraggingThis;
|
||||
|
||||
return (
|
||||
<ConnectionRow
|
||||
<div
|
||||
key={tab.id}
|
||||
isActive={isActive}
|
||||
isLive={isLive}
|
||||
tabType={tab.type}
|
||||
name={tab.host?.name ?? tab.label}
|
||||
subLabel={
|
||||
isLive && tab.type === "terminal"
|
||||
? t("connections.connectedFor", { duration })
|
||||
: isLive
|
||||
? t("connections.connected")
|
||||
: t("connections.disconnected")
|
||||
}
|
||||
icon={tabIcon(tab.type)}
|
||||
onSwitch={() => onSwitchToTab(tab.id)}
|
||||
onClose={() => onCloseTab(tab.id)}
|
||||
/>
|
||||
ref={(el) => {
|
||||
if (el) rowEls.current.set(tab.id, el);
|
||||
else rowEls.current.delete(tab.id);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
dragStartY.current = e.clientY;
|
||||
didDragRef.current = false;
|
||||
setDragTabId(tab.id);
|
||||
}}
|
||||
className={`relative ${isDropTarget ? "border-t-2 border-accent-brand" : ""}`}
|
||||
style={{
|
||||
cursor: dragTabId
|
||||
? isDraggingThis
|
||||
? "grabbing"
|
||||
: "default"
|
||||
: "grab",
|
||||
}}
|
||||
>
|
||||
<ConnectionRow
|
||||
isActive={isActive}
|
||||
isLive={isLive}
|
||||
tabType={tab.type}
|
||||
name={displayName}
|
||||
hostName={tab.customLabel ? hostName : undefined}
|
||||
subLabel={
|
||||
isLive && tab.type === "terminal"
|
||||
? t("connections.connectedFor", { duration })
|
||||
: isLive
|
||||
? t("connections.connected")
|
||||
: t("connections.disconnected")
|
||||
}
|
||||
icon={tabIcon(tab.type)}
|
||||
onSwitch={() => {
|
||||
if (!didDragRef.current) onSwitchToTab(tab.id);
|
||||
}}
|
||||
onClose={() => onCloseTab(tab.id)}
|
||||
onRename={
|
||||
onRenameTab
|
||||
? (newLabel) => onRenameTab(tab.id, newLabel)
|
||||
: undefined
|
||||
}
|
||||
isDragging={isDraggingThis}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -56,6 +56,10 @@ export function CredentialEditorView({
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!credForm.name.trim()) {
|
||||
toast.error(t("hosts.credentialNameRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = {
|
||||
@@ -92,8 +96,9 @@ export function CredentialEditorView({
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
onSave(saved);
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToSaveCredential"));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
toast.error(msg || t("hosts.failedToSaveCredential"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -327,7 +332,7 @@ export function CredentialEditorView({
|
||||
<input
|
||||
ref={credFileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
accept=".pem,.key,.txt"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
@@ -32,8 +32,9 @@ import {
|
||||
subscribeTunnelStatuses,
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
getUserInfo,
|
||||
} from "@/main-axios";
|
||||
import { getTailscaleDevices } from "@/api/settings-api";
|
||||
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHost, TunnelStatus } from "@/types";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
@@ -110,6 +111,13 @@ export function HostEditor({
|
||||
const [tailscaleHasApiKey, setTailscaleHasApiKey] = useState(false);
|
||||
const [tailscaleLoading, setTailscaleLoading] = useState(false);
|
||||
const [connectingTunnel, setConnectingTunnel] = useState<number | null>(null);
|
||||
const [isOidcUser, setIsOidcUser] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getUserInfo()
|
||||
.then((info) => setIsOidcUser(info.is_oidc))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getSnippets()
|
||||
@@ -117,6 +125,13 @@ export function HostEditor({
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (host) return;
|
||||
getHostDefaults()
|
||||
.then((d) => setForm(createHostEditorForm(null, d)))
|
||||
.catch(() => {});
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "tunnels") return;
|
||||
const unsub = subscribeTunnelStatuses((s) => setTunnelStatuses(s));
|
||||
@@ -266,8 +281,19 @@ export function HostEditor({
|
||||
!!selectedCredential?.username &&
|
||||
!form.overrideCredentialUsername
|
||||
}
|
||||
onFocus={() => {
|
||||
if (form.username === "root") setField("username", "");
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (form.username === "") setField("username", "root");
|
||||
}}
|
||||
onChange={(e) => setField("username", e.target.value)}
|
||||
/>
|
||||
{isOidcUser && (
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("hosts.oidcUsernameHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{authMethod === "password" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -337,7 +363,7 @@ export function HostEditor({
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
accept=".pem,.key,.txt"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -566,6 +592,18 @@ export function HostEditor({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{authMethod === "warpgate" && (
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.warpgateLabel")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.warpgateDesc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("hosts.forceKeyboardInteractiveLabel")}
|
||||
description={t("hosts.forceKeyboardInteractiveShortDesc")}
|
||||
@@ -575,6 +613,18 @@ export function HostEditor({
|
||||
onChange={(v) => setField("forceKeyboardInteractive", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.allowLegacyAlgorithmsLabel")}
|
||||
badge={
|
||||
form.allowLegacyAlgorithms ? t("hosts.insecure") : undefined
|
||||
}
|
||||
description={t("hosts.allowLegacyAlgorithmsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.allowLegacyAlgorithms}
|
||||
onChange={(v) => setField("allowLegacyAlgorithms", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.sudoPasswordAutoFillLabel")}
|
||||
description={t("hosts.sudoPasswordAutoFillDesc")}
|
||||
@@ -856,6 +906,44 @@ export function HostEditor({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backgroundImageLabel")}
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.backgroundImageDesc")}
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
value={form.backgroundImage}
|
||||
onChange={(e) =>
|
||||
setField("backgroundImage", e.target.value)
|
||||
}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
{form.backgroundImage && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backgroundImageOpacityLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{Math.round(form.backgroundImageOpacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.05}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={[form.backgroundImageOpacity]}
|
||||
onValueChange={([v]) =>
|
||||
setField("backgroundImageOpacity", v)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -891,6 +979,15 @@ export function HostEditor({
|
||||
onChange={(v) => setField("agentForwarding", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.useSSHTitleLabel")}
|
||||
description={t("hosts.useSSHTitleDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.useSSHTitle}
|
||||
onChange={(v) => setField("useSSHTitle", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.enableAutoMosh")}
|
||||
description={t("hosts.enableAutoMoshDesc")}
|
||||
@@ -963,6 +1060,34 @@ export function HostEditor({
|
||||
onChange={(v) => setField("enableCommandHistory", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.linkClickBehaviorLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.linkClickBehavior}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"linkClickBehavior",
|
||||
e.target.value as "default" | "confirm" | "direct",
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="default">
|
||||
{t("hosts.linkClickBehaviorDefault")}
|
||||
</option>
|
||||
<option value="confirm">
|
||||
{t("hosts.linkClickBehaviorConfirm")}
|
||||
</option>
|
||||
<option value="direct">
|
||||
{t("hosts.linkClickBehaviorDirect")}
|
||||
</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.linkClickBehaviorDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
@@ -1540,6 +1665,7 @@ export function HostEditor({
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={host}
|
||||
credentials={credentials}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1549,6 +1675,7 @@ export function HostEditor({
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={host}
|
||||
credentials={credentials}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1557,6 +1684,8 @@ export function HostEditor({
|
||||
form={form}
|
||||
setField={setField}
|
||||
setGuacField={setGuacField}
|
||||
host={host}
|
||||
credentials={credentials}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TERMINAL_THEMES } from "@/lib/terminal-themes";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHostData } from "@/types";
|
||||
import type { HostDefaults } from "@/api/settings-api";
|
||||
|
||||
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
|
||||
|
||||
@@ -42,8 +43,12 @@ export function mapSnippetResponse(
|
||||
}));
|
||||
}
|
||||
|
||||
export function createHostEditorForm(host: Host | null) {
|
||||
const rawTheme = host?.terminalConfig?.theme;
|
||||
export function createHostEditorForm(
|
||||
host: Host | null,
|
||||
defaults?: HostDefaults,
|
||||
) {
|
||||
const d = host ? undefined : defaults;
|
||||
const rawTheme = host?.terminalConfig?.theme ?? d?.theme;
|
||||
const normalizedTheme =
|
||||
!rawTheme ||
|
||||
["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes(
|
||||
@@ -57,12 +62,13 @@ export function createHostEditorForm(host: Host | null) {
|
||||
return {
|
||||
name: host?.name ?? "",
|
||||
ip: host?.ip ?? "",
|
||||
username: host?.username ?? "",
|
||||
username: host?.username ?? (host ? "" : "root"),
|
||||
sshPort: host?.sshPort ?? host?.port ?? 22,
|
||||
rdpPort: host?.rdpPort ?? 3389,
|
||||
vncPort: host?.vncPort ?? 5900,
|
||||
telnetPort: host?.telnetPort ?? 23,
|
||||
authType: host?.authType ?? "password",
|
||||
useWarpgate: host?.useWarpgate ?? false,
|
||||
password: host?.password ?? "",
|
||||
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
|
||||
keyPassword: host?.hasKeyPassword
|
||||
@@ -70,7 +76,9 @@ export function createHostEditorForm(host: Host | null) {
|
||||
: (host?.keyPassword ?? ""),
|
||||
keyType: host?.keyType ?? "auto",
|
||||
keySubTab: "paste" as "paste" | "upload",
|
||||
credentialId: host?.credentialId ?? "",
|
||||
credentialId:
|
||||
host?.credentialId ??
|
||||
(d?.credentialId != null ? String(d.credentialId) : ""),
|
||||
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
|
||||
folder: host?.folder ?? "",
|
||||
tags: host?.tags ?? ([] as string[]),
|
||||
@@ -78,24 +86,29 @@ export function createHostEditorForm(host: Host | null) {
|
||||
notes: host?.notes ?? "",
|
||||
pin: host?.pin ?? false,
|
||||
macAddress: host?.macAddress ?? "",
|
||||
useSocks5: host?.useSocks5 ?? false,
|
||||
socks5Host: host?.socks5Host ?? "",
|
||||
socks5Port: host?.socks5Port ?? 1080,
|
||||
socks5Username: host?.socks5Username ?? "",
|
||||
socks5Password: host?.socks5Password ?? "",
|
||||
wolBroadcastAddress: host?.wolBroadcastAddress ?? "",
|
||||
useSocks5: host?.useSocks5 ?? d?.useSocks5 ?? false,
|
||||
socks5Host: host?.socks5Host ?? d?.socks5Host ?? "",
|
||||
socks5Port: host?.socks5Port ?? d?.socks5Port ?? 1080,
|
||||
socks5Username: host?.socks5Username ?? d?.socks5Username ?? "",
|
||||
socks5Password: host?.socks5Password ?? d?.socks5Password ?? "",
|
||||
socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0
|
||||
? "chain"
|
||||
: "single") as "single" | "chain",
|
||||
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
|
||||
enableTerminal: host?.enableTerminal ?? true,
|
||||
enableSessionLogging: host?.enableSessionLogging ?? true,
|
||||
enableCommandHistory: host?.enableCommandHistory ?? true,
|
||||
enableSessionLogging:
|
||||
host?.enableSessionLogging ?? d?.enableSessionLogging ?? true,
|
||||
enableCommandHistory:
|
||||
host?.enableCommandHistory ?? d?.enableCommandHistory ?? true,
|
||||
enableFileManager: host?.enableFileManager ?? false,
|
||||
scpLegacy: host?.scpLegacy ?? false,
|
||||
enableDocker: host?.enableDocker ?? false,
|
||||
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
||||
enableProxmox: host?.enableProxmox ?? false,
|
||||
proxmoxConfig: host?.proxmoxConfig ?? {
|
||||
defaultCredentialId: null as number | null,
|
||||
defaultAuthType: "password" as string,
|
||||
windowsPatterns: "win, windows",
|
||||
dockerPatterns: "docker",
|
||||
preferredPrefixes: "10., 192.168.",
|
||||
@@ -103,15 +116,16 @@ export function createHostEditorForm(host: Host | null) {
|
||||
enableTunnel: host?.enableTunnel ?? false,
|
||||
defaultPath: host?.defaultPath ?? "/",
|
||||
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
|
||||
fontSize: host?.terminalConfig?.fontSize ?? 14,
|
||||
fontSize: host?.terminalConfig?.fontSize ?? d?.fontSize ?? 14,
|
||||
fontFamily:
|
||||
host?.terminalConfig?.fontFamily ?? "Caskaydia Cove Nerd Font Mono",
|
||||
host?.terminalConfig?.fontFamily ??
|
||||
d?.fontFamily ??
|
||||
"Caskaydia Cove Nerd Font Mono",
|
||||
theme: normalizedTheme,
|
||||
cursorStyle: (host?.terminalConfig?.cursorStyle ?? "bar") as
|
||||
| "block"
|
||||
| "underline"
|
||||
| "bar",
|
||||
cursorBlink: host?.terminalConfig?.cursorBlink ?? true,
|
||||
cursorStyle: (host?.terminalConfig?.cursorStyle ??
|
||||
d?.cursorStyle ??
|
||||
"bar") as "block" | "underline" | "bar",
|
||||
cursorBlink: host?.terminalConfig?.cursorBlink ?? d?.cursorBlink ?? true,
|
||||
scrollback: host?.terminalConfig?.scrollback ?? 10000,
|
||||
letterSpacing: host?.terminalConfig?.letterSpacing ?? 0,
|
||||
lineHeight: host?.terminalConfig?.lineHeight ?? 1.0,
|
||||
@@ -139,6 +153,13 @@ export function createHostEditorForm(host: Host | null) {
|
||||
sudoPassword: host?.terminalConfig?.sudoPassword ?? "",
|
||||
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
|
||||
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
|
||||
backgroundImage: host?.terminalConfig?.backgroundImage ?? "",
|
||||
backgroundImageOpacity:
|
||||
host?.terminalConfig?.backgroundImageOpacity ?? 0.15,
|
||||
allowLegacyAlgorithms: host?.terminalConfig?.allowLegacyAlgorithms ?? true,
|
||||
linkClickBehavior: (host?.terminalConfig?.linkClickBehavior ??
|
||||
"default") as "default" | "confirm" | "direct",
|
||||
useSSHTitle: host?.terminalConfig?.useSSHTitle ?? false,
|
||||
syntaxHighlighting: host?.terminalConfig?.syntaxHighlighting ?? true,
|
||||
syntaxHighlightingOptions: {
|
||||
logLevels:
|
||||
@@ -161,21 +182,37 @@ export function createHostEditorForm(host: Host | null) {
|
||||
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
|
||||
quickActions:
|
||||
host?.quickActions ?? ([] as { name: string; snippetId: string }[]),
|
||||
rdpCredentialId: host?.rdpCredentialId ?? "",
|
||||
rdpUser: host?.rdpUser ?? "",
|
||||
rdpPassword: host?.rdpPassword ?? "",
|
||||
domain: host?.domain ?? "",
|
||||
security: host?.security ?? "",
|
||||
ignoreCert: host?.ignoreCert ?? false,
|
||||
vncCredentialId: host?.vncCredentialId ?? "",
|
||||
vncPassword: host?.vncPassword ?? "",
|
||||
vncUser: host?.vncUser ?? "",
|
||||
telnetUser: host?.telnetUser ?? "",
|
||||
telnetPassword: host?.telnetPassword ?? "",
|
||||
telnetCredentialId:
|
||||
host?.telnetCredentialId != null ? String(host.telnetCredentialId) : "",
|
||||
rdpAuthType: (host?.rdpAuthType ??
|
||||
(host?.rdpCredentialId ? "credential" : "direct")) as
|
||||
| "direct"
|
||||
| "credential",
|
||||
vncAuthType: (host?.vncAuthType ??
|
||||
(host?.vncCredentialId ? "credential" : "direct")) as
|
||||
| "direct"
|
||||
| "credential",
|
||||
telnetAuthType: (host?.telnetAuthType ??
|
||||
(host?.telnetCredentialId ? "credential" : "direct")) as
|
||||
| "direct"
|
||||
| "credential",
|
||||
guacamoleConfig: host?.guacamoleConfig ?? {},
|
||||
statsConfig: host?.statsConfig ?? {
|
||||
statusCheckEnabled: true,
|
||||
statusCheckEnabled: d?.statusCheckEnabled ?? true,
|
||||
statusCheckInterval: 60,
|
||||
useGlobalStatusInterval: true,
|
||||
metricsEnabled: true,
|
||||
metricsEnabled: d?.metricsEnabled ?? true,
|
||||
metricsInterval: 30,
|
||||
useGlobalMetricsInterval: true,
|
||||
enabledWidgets: [
|
||||
@@ -229,6 +266,7 @@ export function buildHostEditorPayload(
|
||||
tags: form.tags,
|
||||
pin: form.pin,
|
||||
authType: form.authType,
|
||||
useWarpgate: form.useWarpgate,
|
||||
password: usesPassword ? form.password || null : null,
|
||||
key: usesKey
|
||||
? form.key === "existing_key"
|
||||
@@ -246,11 +284,13 @@ export function buildHostEditorPayload(
|
||||
overrideCredentialUsername: form.overrideCredentialUsername,
|
||||
notes: form.notes,
|
||||
macAddress: form.macAddress || null,
|
||||
wolBroadcastAddress: form.wolBroadcastAddress || null,
|
||||
enableTerminal: form.enableTerminal,
|
||||
enableSessionLogging: form.enableSessionLogging,
|
||||
enableCommandHistory: form.enableCommandHistory,
|
||||
enableTunnel: form.enableTunnel,
|
||||
enableFileManager: form.enableFileManager,
|
||||
scpLegacy: form.scpLegacy,
|
||||
enableDocker: form.enableDocker,
|
||||
enableTmuxMonitor: form.enableTmuxMonitor,
|
||||
enableProxmox: form.enableProxmox,
|
||||
@@ -276,15 +316,54 @@ export function buildHostEditorPayload(
|
||||
vncPort: Number(form.vncPort),
|
||||
telnetPort: Number(form.telnetPort),
|
||||
forceKeyboardInteractive: form.forceKeyboardInteractive,
|
||||
rdpUser: form.rdpUser || null,
|
||||
rdpPassword: form.rdpPassword || null,
|
||||
rdpAuthType: protocols.enableRdp ? form.rdpAuthType : null,
|
||||
rdpCredentialId:
|
||||
protocols.enableRdp &&
|
||||
form.rdpAuthType === "credential" &&
|
||||
form.rdpCredentialId
|
||||
? Number(form.rdpCredentialId)
|
||||
: null,
|
||||
rdpUser:
|
||||
protocols.enableRdp && form.rdpAuthType === "direct"
|
||||
? form.rdpUser || null
|
||||
: null,
|
||||
rdpPassword:
|
||||
protocols.enableRdp && form.rdpAuthType === "direct"
|
||||
? form.rdpPassword || null
|
||||
: null,
|
||||
rdpDomain: form.domain || null,
|
||||
rdpSecurity: form.security || null,
|
||||
rdpIgnoreCert: form.ignoreCert,
|
||||
vncPassword: form.vncPassword || null,
|
||||
vncUser: form.vncUser || null,
|
||||
telnetUser: form.telnetUser || null,
|
||||
telnetPassword: form.telnetPassword || null,
|
||||
vncAuthType: protocols.enableVnc ? form.vncAuthType : null,
|
||||
vncCredentialId:
|
||||
protocols.enableVnc &&
|
||||
form.vncAuthType === "credential" &&
|
||||
form.vncCredentialId
|
||||
? Number(form.vncCredentialId)
|
||||
: null,
|
||||
vncPassword:
|
||||
protocols.enableVnc && form.vncAuthType === "direct"
|
||||
? form.vncPassword || null
|
||||
: null,
|
||||
vncUser:
|
||||
protocols.enableVnc && form.vncAuthType === "direct"
|
||||
? form.vncUser || null
|
||||
: null,
|
||||
telnetAuthType: protocols.enableTelnet ? form.telnetAuthType : null,
|
||||
telnetCredentialId:
|
||||
protocols.enableTelnet &&
|
||||
form.telnetAuthType === "credential" &&
|
||||
form.telnetCredentialId
|
||||
? Number(form.telnetCredentialId)
|
||||
: null,
|
||||
telnetUser:
|
||||
protocols.enableTelnet && form.telnetAuthType === "direct"
|
||||
? form.telnetUser || null
|
||||
: null,
|
||||
telnetPassword:
|
||||
protocols.enableTelnet && form.telnetAuthType === "direct"
|
||||
? form.telnetPassword || null
|
||||
: null,
|
||||
jumpHosts: form.jumpHosts,
|
||||
portKnockSequence: form.portKnockSequence,
|
||||
tunnelConnections: form.serverTunnels,
|
||||
@@ -324,8 +403,16 @@ export function buildHostEditorPayload(
|
||||
keepaliveInterval: Number(form.keepaliveInterval),
|
||||
keepaliveCountMax: Number(form.keepaliveCountMax),
|
||||
environmentVariables: form.environmentVariables,
|
||||
useSSHTitle: form.useSSHTitle,
|
||||
syntaxHighlighting: form.syntaxHighlighting,
|
||||
syntaxHighlightingOptions: form.syntaxHighlightingOptions,
|
||||
backgroundImage: form.backgroundImage || null,
|
||||
backgroundImageOpacity: Number(form.backgroundImageOpacity),
|
||||
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
|
||||
linkClickBehavior:
|
||||
form.linkClickBehavior !== "default"
|
||||
? form.linkClickBehavior
|
||||
: undefined,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -72,6 +72,7 @@ export function HostProxmoxTab({
|
||||
|
||||
const cfg = form.proxmoxConfig ?? {
|
||||
defaultCredentialId: null,
|
||||
defaultAuthType: "password",
|
||||
windowsPatterns: "win, windows",
|
||||
dockerPatterns: "docker",
|
||||
preferredPrefixes: "10., 192.168.",
|
||||
@@ -106,6 +107,29 @@ export function HostProxmoxTab({
|
||||
</SettingRow>
|
||||
{form.enableProxmox && (
|
||||
<>
|
||||
<SettingRow
|
||||
label={t("hosts.proxmoxDefaultAuthType")}
|
||||
description={t("hosts.proxmoxDefaultAuthTypeDesc")}
|
||||
>
|
||||
<select
|
||||
value={cfg.defaultAuthType ?? "password"}
|
||||
onChange={(e) =>
|
||||
setField("proxmoxConfig", {
|
||||
...cfg,
|
||||
defaultAuthType: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="password">{t("hosts.authTypePassword")}</option>
|
||||
<option value="key">{t("hosts.authTypeKey")}</option>
|
||||
<option value="credential">
|
||||
{t("hosts.authTypeCredential")}
|
||||
</option>
|
||||
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
|
||||
<option value="none">{t("hosts.authTypeNone")}</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.proxmoxDefaultCredential")}
|
||||
description={t("hosts.proxmoxDefaultCredentialDesc")}
|
||||
@@ -209,6 +233,15 @@ export function HostFilesTab({
|
||||
onChange={(v) => setField("enableFileManager", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.scpLegacyLabel")}
|
||||
description={t("hosts.scpLegacyDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.scpLegacy}
|
||||
onChange={(v) => setField("scpLegacy", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.defaultPathLabel")}
|
||||
|
||||
@@ -72,12 +72,22 @@ export function HostEditorGeneralTab({
|
||||
|
||||
// Folders come from two sources: paths referenced by existing hosts, and
|
||||
// standalone folder records (including empty ones just created).
|
||||
// Intermediate ancestor paths are expanded so "A" appears even when only
|
||||
// "A / B" is directly stored on a host.
|
||||
const folderPaths = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
const addWithAncestors = (path: string) => {
|
||||
const parts = path.split(" / ");
|
||||
let accumulated = "";
|
||||
for (const part of parts) {
|
||||
accumulated = accumulated ? `${accumulated} / ${part}` : part;
|
||||
set.add(accumulated);
|
||||
}
|
||||
};
|
||||
for (const h of hosts) {
|
||||
if (h.folder) set.add(h.folder);
|
||||
if (h.folder) addWithAncestors(h.folder);
|
||||
}
|
||||
for (const path of folderMeta.keys()) set.add(path);
|
||||
for (const path of folderMeta.keys()) addWithAncestors(path);
|
||||
return [...set];
|
||||
}, [hosts, folderMeta]);
|
||||
|
||||
@@ -199,6 +209,23 @@ export function HostEditorGeneralTab({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{protocols.enableSsh && form.macAddress && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.wolBroadcastAddress")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="192.168.1.255"
|
||||
value={form.wolBroadcastAddress}
|
||||
onChange={(e) =>
|
||||
setField("wolBroadcastAddress", e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("hosts.wolBroadcastAddressDesc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Shield,
|
||||
Terminal,
|
||||
Zap,
|
||||
Cpu,
|
||||
} from "lucide-react";
|
||||
import type { HostEditorForm } from "./HostEditorData";
|
||||
|
||||
@@ -42,11 +43,13 @@ export function HostEditorRdpTab({
|
||||
setField,
|
||||
setGuacField,
|
||||
host,
|
||||
credentials,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
setGuacField: GuacFieldSetter;
|
||||
host?: Host | null;
|
||||
credentials?: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -78,41 +81,181 @@ export function HostEditorRdpTab({
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.authentication")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
title={t("hosts.statusChecksLabel")}
|
||||
icon={<Activity className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-0 py-1">
|
||||
<SettingRow
|
||||
label={t("hosts.enableStatusChecks")}
|
||||
description={t("hosts.enableStatusChecksDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.statusCheckEnabled}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckEnabled: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.statsConfig.statusCheckEnabled && (
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalInterval")}
|
||||
description={t("hosts.useGlobalIntervalDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.useGlobalStatusInterval}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
useGlobalStatusInterval: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{form.statsConfig.statusCheckEnabled &&
|
||||
!form.statsConfig.useGlobalStatusInterval && (
|
||||
<SettingRow
|
||||
label={t("hosts.checkIntervalS")}
|
||||
description={t("hosts.checkIntervalDesc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.statsConfig.statusCheckInterval}
|
||||
onChange={(e) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.guacdProxy")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.username")}
|
||||
{t("hosts.guac.guacdHostname")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Administrator"
|
||||
value={form.rdpUser}
|
||||
onChange={(e) => setField("rdpUser", e.target.value)}
|
||||
placeholder={t("hosts.guac.guacdHostnamePlaceholder")}
|
||||
value={(form.guacamoleConfig["guacd-hostname"] as string) ?? ""}
|
||||
onChange={(e) => setGuacField("guacd-hostname", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.rdpPassword}
|
||||
onChange={(e) => setField("rdpPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.domain")}
|
||||
{t("hosts.guac.guacdPort")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="WORKGROUP"
|
||||
value={form.domain}
|
||||
onChange={(e) => setField("domain", e.target.value)}
|
||||
type="number"
|
||||
placeholder="4822"
|
||||
value={(form.guacamoleConfig["guacd-port"] as string) ?? ""}
|
||||
onChange={(e) => setGuacField("guacd-port", e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="col-span-full text-[10px] text-muted-foreground -mt-2">
|
||||
{t("hosts.guac.guacdProxyDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.guac.authentication")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
{credentials && credentials.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.authMethod")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["direct", "credential"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setField("rdpAuthType", m)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
|
||||
form.rdpAuthType === m
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{form.rdpAuthType === "credential" &&
|
||||
credentials &&
|
||||
credentials.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5 col-span-full">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.rdpCredentialId}
|
||||
onChange={(e) => setField("rdpCredentialId", e.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.guac.selectCredential")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.username")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Administrator"
|
||||
value={form.rdpUser}
|
||||
onChange={(e) => setField("rdpUser", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.rdpPassword}
|
||||
onChange={(e) => setField("rdpPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.domain")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="WORKGROUP"
|
||||
value={form.domain}
|
||||
onChange={(e) => setField("domain", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -147,6 +290,23 @@ export function HostEditorRdpTab({
|
||||
onChange={(v) => setField("ignoreCert", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.loadBalanceInfo")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="tsv://MS Terminal Services Plugin.1.CollectionName"
|
||||
value={
|
||||
(form.guacamoleConfig["load-balance-info"] as string) ?? ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
setGuacField("load-balance-info", e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.guac.loadBalanceInfoDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -810,11 +970,13 @@ export function HostEditorVncTab({
|
||||
setField,
|
||||
setGuacField,
|
||||
host,
|
||||
credentials,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
setGuacField: GuacFieldSetter;
|
||||
host?: Host | null;
|
||||
credentials?: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -846,31 +1008,169 @@ export function HostEditorVncTab({
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.authentication")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
title={t("hosts.statusChecksLabel")}
|
||||
icon={<Activity className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-0 py-1">
|
||||
<SettingRow
|
||||
label={t("hosts.enableStatusChecks")}
|
||||
description={t("hosts.enableStatusChecksDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.statusCheckEnabled}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckEnabled: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.statsConfig.statusCheckEnabled && (
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalInterval")}
|
||||
description={t("hosts.useGlobalIntervalDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.useGlobalStatusInterval}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
useGlobalStatusInterval: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{form.statsConfig.statusCheckEnabled &&
|
||||
!form.statsConfig.useGlobalStatusInterval && (
|
||||
<SettingRow
|
||||
label={t("hosts.checkIntervalS")}
|
||||
description={t("hosts.checkIntervalDesc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.statsConfig.statusCheckInterval}
|
||||
onChange={(e) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.guacdProxy")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.vncPassword")}
|
||||
{t("hosts.guac.guacdHostname")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.vncPassword}
|
||||
onChange={(e) => setField("vncPassword", e.target.value)}
|
||||
<Input
|
||||
placeholder={t("hosts.guac.guacdHostnamePlaceholder")}
|
||||
value={(form.guacamoleConfig["guacd-hostname"] as string) ?? ""}
|
||||
onChange={(e) => setGuacField("guacd-hostname", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.vncUsernameOptional")}
|
||||
{t("hosts.guac.guacdPort")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("hosts.guac.vncLeaveBlank")}
|
||||
value={form.vncUser}
|
||||
onChange={(e) => setField("vncUser", e.target.value)}
|
||||
type="number"
|
||||
placeholder="4822"
|
||||
value={(form.guacamoleConfig["guacd-port"] as string) ?? ""}
|
||||
onChange={(e) => setGuacField("guacd-port", e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="col-span-full text-[10px] text-muted-foreground -mt-2">
|
||||
{t("hosts.guac.guacdProxyDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.guac.authentication")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
{credentials && credentials.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.authMethod")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["direct", "credential"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setField("vncAuthType", m)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
|
||||
form.vncAuthType === m
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{form.vncAuthType === "credential" &&
|
||||
credentials &&
|
||||
credentials.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.vncCredentialId}
|
||||
onChange={(e) => setField("vncCredentialId", e.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.guac.selectCredential")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.vncPassword")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.vncPassword}
|
||||
onChange={(e) => setField("vncPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.vncUsernameOptional")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("hosts.guac.vncLeaveBlank")}
|
||||
value={form.vncUser}
|
||||
onChange={(e) => setField("vncUser", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -981,6 +1281,28 @@ export function HostEditorVncTab({
|
||||
<option value="remote">Remote</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.serverLayout")}
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={form.guacamoleConfig["server-layout"] ?? "auto"}
|
||||
onChange={(e) => setGuacField("server-layout", e.target.value)}
|
||||
>
|
||||
<option value="auto">Auto</option>
|
||||
<option>en-us-qwerty</option>
|
||||
<option>en-gb-qwerty</option>
|
||||
<option>de-de-qwertz</option>
|
||||
<option>fr-fr-azerty</option>
|
||||
<option>it-it-qwerty</option>
|
||||
<option>sv-se-qwerty</option>
|
||||
<option>ja-jp-qwerty</option>
|
||||
<option>pt-br-qwerty</option>
|
||||
<option>es-es-qwerty</option>
|
||||
<option>failsafe</option>
|
||||
</select>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.guac.swapRedBlue")}
|
||||
description={t("hosts.guac.swapRedBlueDesc")}
|
||||
@@ -1193,11 +1515,13 @@ export function HostEditorTelnetTab({
|
||||
form,
|
||||
setField,
|
||||
setGuacField,
|
||||
credentials,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: HostEditorSetField;
|
||||
setGuacField: GuacFieldSetter;
|
||||
host?: Host | null;
|
||||
credentials?: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1229,31 +1553,168 @@ export function HostEditorTelnetTab({
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.authentication")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
title={t("hosts.statusChecksLabel")}
|
||||
icon={<Activity className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-0 py-1">
|
||||
<SettingRow
|
||||
label={t("hosts.enableStatusChecks")}
|
||||
description={t("hosts.enableStatusChecksDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.statusCheckEnabled}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckEnabled: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.statsConfig.statusCheckEnabled && (
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalInterval")}
|
||||
description={t("hosts.useGlobalIntervalDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.useGlobalStatusInterval}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
useGlobalStatusInterval: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{form.statsConfig.statusCheckEnabled &&
|
||||
!form.statsConfig.useGlobalStatusInterval && (
|
||||
<SettingRow
|
||||
label={t("hosts.checkIntervalS")}
|
||||
description={t("hosts.checkIntervalDesc")}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.statsConfig.statusCheckInterval}
|
||||
onChange={(e) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
statusCheckInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.guacdProxy")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.username")}
|
||||
{t("hosts.guac.guacdHostname")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="admin"
|
||||
value={form.telnetUser}
|
||||
onChange={(e) => setField("telnetUser", e.target.value)}
|
||||
placeholder={t("hosts.guac.guacdHostnamePlaceholder")}
|
||||
value={(form.guacamoleConfig["guacd-hostname"] as string) ?? ""}
|
||||
onChange={(e) => setGuacField("guacd-hostname", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.password")}
|
||||
{t("hosts.guac.guacdPort")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.telnetPassword}
|
||||
onChange={(e) => setField("telnetPassword", e.target.value)}
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="4822"
|
||||
value={(form.guacamoleConfig["guacd-port"] as string) ?? ""}
|
||||
onChange={(e) => setGuacField("guacd-port", e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="col-span-full text-[10px] text-muted-foreground -mt-2">
|
||||
{t("hosts.guac.guacdProxyDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.authentication")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
{credentials && credentials.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.authMethod")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["direct", "credential"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setField("telnetAuthType", m)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
|
||||
form.telnetAuthType === m
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{form.telnetAuthType === "credential" &&
|
||||
credentials &&
|
||||
credentials.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.telnetCredentialId}
|
||||
onChange={(e) => setField("telnetCredentialId", e.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.guac.selectCredential")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.username")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="admin"
|
||||
value={form.telnetUser}
|
||||
onChange={(e) => setField("telnetUser", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.telnetPassword}
|
||||
onChange={(e) => setField("telnetPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@@ -50,20 +50,22 @@ export function HostStatsTab({
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalInterval")}
|
||||
description={t("hosts.useGlobalIntervalDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.useGlobalStatusInterval}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
useGlobalStatusInterval: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.statsConfig.statusCheckEnabled && (
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalInterval")}
|
||||
description={t("hosts.useGlobalIntervalDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.useGlobalStatusInterval}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
useGlobalStatusInterval: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{form.statsConfig.statusCheckEnabled &&
|
||||
!form.statsConfig.useGlobalStatusInterval && (
|
||||
<SettingRow
|
||||
@@ -104,20 +106,22 @@ export function HostStatsTab({
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalMetrics")}
|
||||
description={t("hosts.useGlobalMetricsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.useGlobalMetricsInterval}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
useGlobalMetricsInterval: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.statsConfig.metricsEnabled && (
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalMetrics")}
|
||||
description={t("hosts.useGlobalMetricsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statsConfig.useGlobalMetricsInterval}
|
||||
onChange={(v) =>
|
||||
setField("statsConfig", {
|
||||
...form.statsConfig,
|
||||
useGlobalMetricsInterval: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{form.statsConfig.metricsEnabled &&
|
||||
!form.statsConfig.useGlobalMetricsInterval && (
|
||||
<SettingRow
|
||||
|
||||
@@ -62,7 +62,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
enableSessionLogging: h.enableSessionLogging ?? true,
|
||||
enableCommandHistory: h.enableCommandHistory ?? true,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableFileManager: h.enableFileManager ?? true,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableProxmox: h.enableProxmox ?? false,
|
||||
enableTmuxMonitor: h.enableTmuxMonitor ?? false,
|
||||
|
||||
@@ -90,7 +90,8 @@ export function HostShareModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const hasCredential = !!host?.credentialId;
|
||||
const hasCredential =
|
||||
!!host?.credentialId || !!host?.rdpCredentialId || !!host?.vncCredentialId;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import {
|
||||
getSSHHosts,
|
||||
bulkImportSSHHosts,
|
||||
importSSHConfigHosts,
|
||||
exportAllSSHHosts,
|
||||
} from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
@@ -248,6 +249,9 @@ export function HostsPanel({
|
||||
const [proxmoxDefaultCredentialId, setProxmoxDefaultCredentialId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [proxmoxDefaultAuthType, setProxmoxDefaultAuthType] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [proxmoxDefaultUsername, setProxmoxDefaultUsername] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
@@ -267,6 +271,7 @@ export function HostsPanel({
|
||||
});
|
||||
const filterActive = Object.values(filterState).some((arr) => arr.length > 0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const sshConfigInputRef = useRef<HTMLInputElement>(null);
|
||||
const importOverwriteRef = useRef(false);
|
||||
const allTags = [...new Set(rawHosts.flatMap((h) => h.tags ?? []))];
|
||||
|
||||
@@ -501,6 +506,42 @@ export function HostsPanel({
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={sshConfigInputRef}
|
||||
type="file"
|
||||
accept=".conf,.config,*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
try {
|
||||
const text = await file.text();
|
||||
const result = await importSSHConfigHosts(
|
||||
text,
|
||||
importOverwriteRef.current,
|
||||
);
|
||||
const hosts = await getSSHHosts();
|
||||
setRawHosts(hosts);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
const msg = [
|
||||
result.success ? `${result.success} imported` : null,
|
||||
result.updated ? `${result.updated} updated` : null,
|
||||
result.failed ? `${result.failed} failed` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
toast.success(`${t("hosts.importSSHConfig")}: ${msg}`);
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to import SSH config",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -544,6 +585,15 @@ export function HostsPanel({
|
||||
<Upload className="size-3.5 mr-2" />
|
||||
{t("hosts.importOverwrite")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
importOverwriteRef.current = false;
|
||||
sshConfigInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload className="size-3.5 mr-2" />
|
||||
{t("hosts.importSSHConfig")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProxmoxHostId(undefined);
|
||||
@@ -899,6 +949,15 @@ export function HostsPanel({
|
||||
<Upload className="size-3.5 mr-2" />
|
||||
{t("hosts.importOverwrite")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
importOverwriteRef.current = false;
|
||||
sshConfigInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload className="size-3.5 mr-2" />
|
||||
{t("hosts.importSSHConfig")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleExportHosts}
|
||||
disabled={rawHosts.length === 0}
|
||||
@@ -948,6 +1007,7 @@ export function HostsPanel({
|
||||
const cfg = host.proxmoxConfig;
|
||||
setProxmoxHostId(Number(host.id));
|
||||
setProxmoxDefaultCredentialId(cfg?.defaultCredentialId ?? null);
|
||||
setProxmoxDefaultAuthType(cfg?.defaultAuthType ?? undefined);
|
||||
setProxmoxDefaultUsername(undefined);
|
||||
setProxmoxDialogOpen(true);
|
||||
}}
|
||||
@@ -980,6 +1040,7 @@ export function HostsPanel({
|
||||
onHostsChanged={setRawHosts}
|
||||
preselectedHostId={proxmoxHostId}
|
||||
defaultCredentialId={proxmoxDefaultCredentialId}
|
||||
defaultAuthType={proxmoxDefaultAuthType}
|
||||
defaultUsername={proxmoxDefaultUsername}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { getCredentials } from "@/api/credentials-api";
|
||||
import { mapCredentials } from "./HostManagerData";
|
||||
|
||||
interface QuickConnectPanelProps {
|
||||
onConnect: (host: Host, type: "terminal" | "files") => void;
|
||||
@@ -12,13 +14,23 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState("");
|
||||
const [port, setPort] = useState("22");
|
||||
const [username, setUsername] = useState("");
|
||||
const [username, setUsername] = useState("root");
|
||||
const [authType, setAuthType] = useState<"password" | "key" | "credential">(
|
||||
"password",
|
||||
);
|
||||
const [password, setPassword] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [credentialId, setCredentialId] = useState("");
|
||||
const [credentials, setCredentials] = useState<
|
||||
{ id: string; name: string; username: string }[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getCredentials()
|
||||
.then((res) => setCredentials(mapCredentials(res)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const connect = (type: "terminal" | "files") => {
|
||||
if (!host || !username) return;
|
||||
@@ -31,6 +43,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
authType,
|
||||
password: authType === "password" ? password : undefined,
|
||||
key: authType === "key" ? privateKey : undefined,
|
||||
credentialId: authType === "credential" ? credentialId : undefined,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
@@ -94,6 +107,12 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
||||
value={username}
|
||||
onFocus={() => {
|
||||
if (username === "root") setUsername("");
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (username === "") setUsername("root");
|
||||
}}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect("terminal");
|
||||
@@ -172,12 +191,25 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.credentialLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"newUi.sidebar.quickConnect.credentialPlaceholder",
|
||||
)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<select
|
||||
value={credentialId}
|
||||
onChange={(e) => {
|
||||
const newId = e.target.value;
|
||||
setCredentialId(newId);
|
||||
const cred = credentials.find((c) => c.id === newId);
|
||||
if (cred?.username) setUsername(cred.username);
|
||||
}}
|
||||
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">
|
||||
{t("newUi.sidebar.quickConnect.credentialPlaceholder")}
|
||||
</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
|
||||
@@ -51,6 +51,7 @@ function emptyOidc(): OIDCProviderConfig {
|
||||
allowed_users: "",
|
||||
admin_group: "",
|
||||
group_claim: "",
|
||||
ca_cert: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +85,7 @@ type OIDCFields = {
|
||||
allowed_users: string;
|
||||
admin_group: string;
|
||||
group_claim: string;
|
||||
ca_cert: string;
|
||||
};
|
||||
|
||||
type LDAPFields = {
|
||||
@@ -175,6 +177,7 @@ export function SSOProviderDialog({
|
||||
allowed_users: (config.allowed_users as string) ?? d.allowed_users,
|
||||
admin_group: (config.admin_group as string) ?? d.admin_group,
|
||||
group_claim: (config.group_claim as string) ?? "",
|
||||
ca_cert: (config.ca_cert as string) ?? "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -218,19 +221,29 @@ export function SSOProviderDialog({
|
||||
allowedUsers: ldap.allowedUsers || undefined,
|
||||
};
|
||||
}
|
||||
const providerDefaults =
|
||||
type === "google"
|
||||
? googleDefaults
|
||||
: type === "github"
|
||||
? githubDefaults
|
||||
: null;
|
||||
return {
|
||||
client_id: oidc.client_id,
|
||||
client_secret: oidc.client_secret,
|
||||
issuer_url: oidc.issuer_url,
|
||||
authorization_url: oidc.authorization_url,
|
||||
token_url: oidc.token_url,
|
||||
userinfo_url: oidc.userinfo_url || undefined,
|
||||
identifier_path: oidc.identifier_path,
|
||||
name_path: oidc.name_path,
|
||||
scopes: oidc.scopes,
|
||||
issuer_url: oidc.issuer_url || providerDefaults?.issuer_url || "",
|
||||
authorization_url:
|
||||
oidc.authorization_url || providerDefaults?.authorization_url || "",
|
||||
token_url: oidc.token_url || providerDefaults?.token_url || "",
|
||||
userinfo_url:
|
||||
oidc.userinfo_url || providerDefaults?.userinfo_url || undefined,
|
||||
identifier_path:
|
||||
oidc.identifier_path || providerDefaults?.identifier_path || "sub",
|
||||
name_path: oidc.name_path || providerDefaults?.name_path || "name",
|
||||
scopes: oidc.scopes || providerDefaults?.scopes || "openid email profile",
|
||||
allowed_users: oidc.allowed_users || undefined,
|
||||
admin_group: oidc.admin_group || undefined,
|
||||
group_claim: oidc.group_claim || undefined,
|
||||
ca_cert: oidc.ca_cert || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,8 +408,24 @@ function OIDCConfigFields({
|
||||
simplified: boolean;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const githubAuthUrl = "https://github.com/login/oauth/authorize";
|
||||
const googleAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
const githubDefaults = {
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
issuer_url: "https://token.actions.githubusercontent.com",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
scopes: "read:user user:email",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
};
|
||||
const googleDefaults = {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
issuer_url: "https://accounts.google.com",
|
||||
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
scopes: "openid email profile",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
};
|
||||
const docsHref = simplified
|
||||
? "https://docs.termix.site/features/authentication/github-google"
|
||||
: "https://docs.termix.site/features/authentication/oidc";
|
||||
@@ -434,8 +463,8 @@ function OIDCConfigFields({
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{type === "github"
|
||||
? `Authorization URL: ${githubAuthUrl}`
|
||||
: `Authorization URL: ${googleAuthUrl}`}
|
||||
? `Authorization URL: ${githubDefaults.authorization_url}`
|
||||
: `Authorization URL: ${googleDefaults.authorization_url}`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -531,6 +560,20 @@ function OIDCConfigFields({
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcCaCert")}>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcCaCertDesc")}
|
||||
</span>
|
||||
<textarea
|
||||
value={oidc.ca_cert}
|
||||
onChange={(e) => setOidcField("ca_cert", e.target.value)}
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
rows={4}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -247,12 +248,12 @@ export function SessionLogsPanel() {
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!viewContent) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(viewContent);
|
||||
const ok = await copyToClipboard(viewContent);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error("Failed to copy");
|
||||
} else {
|
||||
toast.error(t("common.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -57,11 +57,37 @@ import type { SSHHostData } from "@/types/index";
|
||||
import { FolderIconEl } from "@/components/folder-style";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { FolderMetadataDialog } from "./FolderMetadataDialog";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip";
|
||||
|
||||
export function isFolder(item: Host | HostFolder): item is HostFolder {
|
||||
return "children" in item;
|
||||
}
|
||||
|
||||
function statusCheckEnabled(host: Host): boolean {
|
||||
return host.statsConfig?.statusCheckEnabled !== false;
|
||||
}
|
||||
|
||||
function buildStatusTooltip(host: Host, online: boolean): string {
|
||||
const statusLabel = online ? "Online" : "Offline";
|
||||
if (!statusCheckEnabled(host)) return "Monitoring disabled";
|
||||
const protocols: string[] = [];
|
||||
if (host.enableSsh) protocols.push("SSH");
|
||||
if (host.enableRdp) protocols.push("RDP");
|
||||
if (host.enableVnc) protocols.push("VNC");
|
||||
if (host.enableTelnet) protocols.push("Telnet");
|
||||
if (protocols.length === 0) return statusLabel;
|
||||
return `${protocols.join(", ")}: ${statusLabel}`;
|
||||
}
|
||||
|
||||
function getSshActions(
|
||||
host: Host,
|
||||
): { type: TabType; icon: typeof Terminal; label: string }[] {
|
||||
@@ -273,6 +299,10 @@ export function HostItem({
|
||||
const v = localStorage.getItem("showHostTags");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const [compactHostView, setCompactHostView] = useState(
|
||||
() => localStorage.getItem("compactHostView") === "true",
|
||||
);
|
||||
const statusScheme = useStatusColorScheme();
|
||||
const isTouchOnly =
|
||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||
const shouldUseClickTray = trayOnClick || isTouchOnly;
|
||||
@@ -322,8 +352,521 @@ export function HostItem({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () =>
|
||||
setCompactHostView(localStorage.getItem("compactHostView") === "true");
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener("compactHostViewChanged", handler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handler);
|
||||
window.removeEventListener("compactHostViewChanged", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (query && !hostMatchesQuery(host, query)) return null;
|
||||
|
||||
if (compactHostView) {
|
||||
return (
|
||||
<div
|
||||
draggable={!selectionMode && !isTouchOnly}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
onDragStart?.();
|
||||
}}
|
||||
onDragEnd={() => onDragEnd?.()}
|
||||
className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${
|
||||
selected
|
||||
? "bg-accent-brand/5"
|
||||
: stripeIndex % 2 === 1
|
||||
? "bg-muted/20"
|
||||
: ""
|
||||
} ${isMenuOpen ? "bg-muted/40" : ""}`}
|
||||
onClick={(e) => {
|
||||
if (selectionMode) {
|
||||
onToggleSelect?.();
|
||||
return;
|
||||
}
|
||||
if (isTouchOnly) {
|
||||
e.stopPropagation();
|
||||
const actionCount = getSshActions(host).length;
|
||||
const otherProtocols = [
|
||||
host.enableRdp,
|
||||
host.enableVnc,
|
||||
host.enableTelnet,
|
||||
].filter(Boolean).length;
|
||||
if (actionCount + otherProtocols <= 1) {
|
||||
if (host.enableSsh) onOpenTab("terminal");
|
||||
else if (host.enableRdp) onOpenTab("rdp");
|
||||
else if (host.enableVnc) onOpenTab("vnc");
|
||||
else if (host.enableTelnet) onOpenTab("telnet");
|
||||
else onOpenTab("terminal");
|
||||
} else {
|
||||
onTrayOpenChange?.(!isTrayOpen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (host.enableSsh) onOpenTab("terminal");
|
||||
else if (host.enableRdp) onOpenTab("rdp");
|
||||
else if (host.enableVnc) onOpenTab("vnc");
|
||||
else if (host.enableTelnet) onOpenTab("telnet");
|
||||
else onOpenTab("terminal");
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(host.online, statusScheme, "stripe")}`}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 min-w-0 px-2.5 py-1">
|
||||
{selectionMode && (
|
||||
<div
|
||||
className={`size-3.5 border-2 flex items-center justify-center shrink-0 transition-colors ${selected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{selected && <Check className="size-2 text-background" />}
|
||||
</div>
|
||||
)}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot")}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{buildStatusTooltip(host, host.online)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span className="text-[13px] font-medium truncate text-foreground leading-none">
|
||||
{host.name}
|
||||
</span>
|
||||
{!selectionMode && shouldUseClickTray && (
|
||||
<button
|
||||
title={
|
||||
isTrayOpen
|
||||
? t("hosts.collapseActions")
|
||||
: t("hosts.expandActions")
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTrayOpenChange?.(!isTrayOpen);
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center size-5 rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`size-3 transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{!selectionMode && !shouldUseClickTray && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
{selectionMode && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Click-tray mode: always-visible action buttons */}
|
||||
{shouldUseClickTray && !selectionMode && (
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out ${isTrayOpen || isMenuOpen ? "max-h-[200px] opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab(type);
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
{host.enableSsh &&
|
||||
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
|
||||
getSshActions(host).length > 0 && (
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
)}
|
||||
{host.enableRdp && (
|
||||
<button
|
||||
title="RDP"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("rdp");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Monitor className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableVnc && (
|
||||
<button
|
||||
title="VNC"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("vnc");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MousePointerClick className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableTelnet && (
|
||||
<button
|
||||
title="Telnet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("telnet");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MessagesSquare className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<button
|
||||
title={t("hosts.wakeOnLanAction")}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(
|
||||
t("hosts.wakeOnLanSuccess", { name: host.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.wakeOnLanError"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
{showPasswordCopy && (
|
||||
<button
|
||||
title={t("nav.copyPassword")}
|
||||
onClick={(e) => handleCopyPassword(e, "password")}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Key className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{showSudoPasswordCopy && (
|
||||
<button
|
||||
title={t("nav.copySudoPassword")}
|
||||
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<KeyRound className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onEditHost && (
|
||||
<button
|
||||
title="Edit Host"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost();
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onShareHost && (
|
||||
<button
|
||||
title={t("hosts.shareHost")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShareHost();
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Share2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableProxmox && onProxmoxDiscover && (
|
||||
<button
|
||||
title={t("hosts.proxmoxDiscoverAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProxmoxDiscover();
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Boxes className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
title="More options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
writeClipboardText(`${host.username}@${host.ip}`);
|
||||
toast.success(t("hosts.copiedToClipboard"));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5 mr-2" />
|
||||
{t("hosts.copyAddress")}
|
||||
</DropdownMenuItem>
|
||||
{showPasswordCopy && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleCopyPassword(e, "password")}
|
||||
>
|
||||
<Key className="size-3.5 mr-2" />
|
||||
{t("nav.copyPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showSudoPasswordCopy && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
|
||||
>
|
||||
<KeyRound className="size-3.5 mr-2" />
|
||||
{t("nav.copySudoPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopyPlus className="size-3.5 mr-2" />
|
||||
{t("hosts.cloneHostAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover tray (non-click-tray mode) */}
|
||||
{!shouldUseClickTray && !selectionMode && (
|
||||
<div className="max-h-0 opacity-0 overflow-hidden transition-all duration-150 ease-out group-hover:max-h-[200px] group-hover:opacity-100">
|
||||
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab(type);
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
{host.enableSsh &&
|
||||
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
|
||||
getSshActions(host).length > 0 && (
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
)}
|
||||
{host.enableRdp && (
|
||||
<button
|
||||
title="RDP"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("rdp");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Monitor className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableVnc && (
|
||||
<button
|
||||
title="VNC"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("vnc");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MousePointerClick className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableTelnet && (
|
||||
<button
|
||||
title="Telnet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTab("telnet");
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MessagesSquare className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<button
|
||||
title={t("hosts.wakeOnLanAction")}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(
|
||||
t("hosts.wakeOnLanSuccess", { name: host.name }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.wakeOnLanError"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Zap className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
{showPasswordCopy && (
|
||||
<button
|
||||
title={t("nav.copyPassword")}
|
||||
onClick={(e) => handleCopyPassword(e, "password")}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Key className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{showSudoPasswordCopy && (
|
||||
<button
|
||||
title={t("nav.copySudoPassword")}
|
||||
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<KeyRound className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onEditHost && (
|
||||
<button
|
||||
title="Edit Host"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditHost();
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onShareHost && (
|
||||
<button
|
||||
title={t("hosts.shareHost")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShareHost();
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Share2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{host.enableProxmox && onProxmoxDiscover && (
|
||||
<button
|
||||
title={t("hosts.proxmoxDiscoverAction")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProxmoxDiscover();
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Boxes className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
title="More options"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
writeClipboardText(`${host.username}@${host.ip}`);
|
||||
toast.success(t("hosts.copiedToClipboard"));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3.5 mr-2" />
|
||||
{t("hosts.copyAddress")}
|
||||
</DropdownMenuItem>
|
||||
{showPasswordCopy && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleCopyPassword(e, "password")}
|
||||
>
|
||||
<Key className="size-3.5 mr-2" />
|
||||
{t("nav.copyPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showSudoPasswordCopy && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
|
||||
>
|
||||
<KeyRound className="size-3.5 mr-2" />
|
||||
{t("nav.copySudoPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopyPlus className="size-3.5 mr-2" />
|
||||
{t("hosts.cloneHostAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!selectionMode && !isTouchOnly}
|
||||
@@ -373,7 +916,7 @@ export function HostItem({
|
||||
>
|
||||
{/* Status stripe */}
|
||||
<div
|
||||
className={`w-[3px] shrink-0 transition-colors ${host.online ? "bg-accent-brand" : "bg-transparent"}`}
|
||||
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(host.online, statusScheme, "stripe")}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
|
||||
@@ -386,9 +929,18 @@ export function HostItem({
|
||||
{selected && <Check className="size-2 text-background" />}
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${host.online ? "bg-accent-brand" : "bg-muted-foreground/25"}`}
|
||||
/>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot")}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{buildStatusTooltip(host, host.online)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<span className="text-[13px] font-medium truncate text-foreground leading-none">
|
||||
{host.name}
|
||||
</span>
|
||||
@@ -1309,11 +1861,14 @@ export function SidebarTree({
|
||||
pin: host.pin ?? false,
|
||||
notes: host.notes,
|
||||
macAddress: host.macAddress,
|
||||
authType: host.authType,
|
||||
password: host.password ?? null,
|
||||
key: host.key ?? null,
|
||||
keyPassword: host.keyPassword ?? null,
|
||||
keyType: host.keyType ?? null,
|
||||
// Key material is never sent to the frontend, so a cloned key-auth
|
||||
// host would have authType "key" with no key — unusable. Reset to
|
||||
// password so the clone is in a connectable (editable) state.
|
||||
authType: host.authType === "key" ? "password" : host.authType,
|
||||
password: host.authType === "key" ? null : (host.password ?? null),
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
credentialId: host.credentialId ? Number(host.credentialId) : null,
|
||||
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
|
||||
enableSsh: host.enableSsh,
|
||||
@@ -1654,6 +2209,26 @@ export function SidebarTree({
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={() => {
|
||||
const selectedHosts = allHosts.filter((h) =>
|
||||
selectedHostIds.has(String(h.id)),
|
||||
);
|
||||
for (const host of selectedHosts) {
|
||||
if (host.enableSsh) onOpenTab(host, "terminal");
|
||||
else if (host.enableRdp) onOpenTab(host, "rdp");
|
||||
else if (host.enableVnc) onOpenTab(host, "vnc");
|
||||
else if (host.enableTelnet) onOpenTab(host, "telnet");
|
||||
}
|
||||
setSelectedHostIds(new Set());
|
||||
onToggleSelectionMode();
|
||||
}}
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
{t("hosts.connectSelected")}
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-destructive hover:text-destructive px-1.5 py-1 hover:bg-destructive/10 rounded transition-colors disabled:opacity-40"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
|
||||
@@ -19,6 +19,14 @@ import {
|
||||
getRoles,
|
||||
reorderSnippets,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
exportSnippets,
|
||||
importSnippets,
|
||||
type SnippetExportData,
|
||||
executeSnippet as apiExecuteSnippet,
|
||||
} from "@/api/snippets-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Separator } from "@/components/separator";
|
||||
@@ -35,6 +43,7 @@ import {
|
||||
Copy,
|
||||
Cpu,
|
||||
Database,
|
||||
Download,
|
||||
Folder,
|
||||
Globe,
|
||||
GripVertical,
|
||||
@@ -48,9 +57,11 @@ import {
|
||||
Share2,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Upload,
|
||||
UserPlus,
|
||||
X,
|
||||
MoreHorizontal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -121,18 +132,23 @@ function SnippetFormDialog({
|
||||
folders,
|
||||
snippet,
|
||||
onSave,
|
||||
availableHosts,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
folders: SnippetFolder[];
|
||||
snippet: Snippet | null;
|
||||
onSave: (data: Omit<Snippet, "id" | "order">, id?: number) => void;
|
||||
availableHosts: SSHHost[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [folder, setFolder] = useState<string | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -140,9 +156,22 @@ function SnippetFormDialog({
|
||||
setDescription(snippet?.description ?? "");
|
||||
setFolder(snippet?.folder ?? null);
|
||||
setContent(snippet?.content ?? "");
|
||||
setSelectedHostIds(new Set(snippet?.hostIds ?? []));
|
||||
}
|
||||
}, [open, snippet]);
|
||||
|
||||
function toggleHost(id: number) {
|
||||
setSelectedHostIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!name.trim() || !content.trim()) return;
|
||||
onSave(
|
||||
@@ -151,6 +180,8 @@ function SnippetFormDialog({
|
||||
description: description.trim() || undefined,
|
||||
content: content.trim(),
|
||||
folder,
|
||||
hostIds:
|
||||
selectedHostIds.size > 0 ? Array.from(selectedHostIds) : undefined,
|
||||
},
|
||||
snippet?.id,
|
||||
);
|
||||
@@ -234,6 +265,67 @@ function SnippetFormDialog({
|
||||
className="w-full h-36 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<Server className="size-3.5" />
|
||||
{t("newUi.sidebar.snippets.targetHostsLabel")}{" "}
|
||||
<span className="font-normal">
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{t("newUi.sidebar.snippets.targetHostsHint")}
|
||||
</p>
|
||||
{availableHosts.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
{t("newUi.sidebar.snippets.noHostsAvailable")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-32 overflow-y-auto border border-border p-1.5">
|
||||
{availableHosts.map((host) => {
|
||||
const selected = selectedHostIds.has(host.id);
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
type="button"
|
||||
onClick={() => toggleHost(host.id)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left transition-colors ${
|
||||
selected
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="size-1.5 bg-background" />}
|
||||
</div>
|
||||
<Server className="size-3 shrink-0 opacity-60" />
|
||||
<span className="text-xs font-medium truncate flex-1">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60 shrink-0">
|
||||
{host.ip}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedHostIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedHostIds(new Set())}
|
||||
className="text-xs text-muted-foreground hover:text-foreground self-start"
|
||||
>
|
||||
{t("newUi.sidebar.snippets.clearTargetHosts")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
@@ -503,10 +595,36 @@ type AccessRecord = {
|
||||
};
|
||||
|
||||
type SnippetPayload = Omit<Snippet, "id" | "order">;
|
||||
type RawSnippet = SnippetPayload & {
|
||||
type RawSnippet = Omit<SnippetPayload, "hostIds"> & {
|
||||
id: number;
|
||||
order?: number | null;
|
||||
hostFilter?: string | null;
|
||||
};
|
||||
|
||||
function parseHostFilter(raw: string | null | undefined): number[] | undefined {
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((x) => typeof x === "number")) {
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed values
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapRawSnippet(s: RawSnippet): Snippet {
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
content: s.content,
|
||||
folder: s.folder ?? null,
|
||||
order: s.order ?? 0,
|
||||
hostIds: parseHostFilter(s.hostFilter),
|
||||
};
|
||||
}
|
||||
type RawSnippetFolder = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -694,32 +812,44 @@ function SnippetCard({
|
||||
snippet,
|
||||
selectedTabIds,
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onShare,
|
||||
onConfirmRun,
|
||||
onDirectExecute,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
dropIndicator,
|
||||
isDragging,
|
||||
availableHosts,
|
||||
t,
|
||||
}: {
|
||||
snippet: Snippet;
|
||||
selectedTabIds: Set<string>;
|
||||
terminalTabs: Tab[];
|
||||
activeTabId: string;
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (snippet: Snippet) => void;
|
||||
onShare: (snippet: Snippet) => void;
|
||||
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
|
||||
onDirectExecute: (snippet: Snippet) => void;
|
||||
onDragStart: () => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
dropIndicator: "above" | "below" | null;
|
||||
isDragging: boolean;
|
||||
availableHosts: SSHHost[];
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
}) {
|
||||
const hasTargetHosts = (snippet.hostIds?.length ?? 0) > 0;
|
||||
|
||||
function executeRun() {
|
||||
if (hasTargetHosts) {
|
||||
onDirectExecute(snippet);
|
||||
return;
|
||||
}
|
||||
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
|
||||
if (targets.length > 0) {
|
||||
targets.forEach((tab) => {
|
||||
@@ -732,7 +862,9 @@ function SnippetCard({
|
||||
}),
|
||||
);
|
||||
} else if (terminalTabs.length > 0) {
|
||||
terminalTabs[0].terminalRef?.current?.sendInput?.(snippet.content + "\r");
|
||||
const activeTab =
|
||||
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
|
||||
activeTab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.runSuccess", {
|
||||
name: snippet.name,
|
||||
@@ -758,6 +890,10 @@ function SnippetCard({
|
||||
);
|
||||
}
|
||||
|
||||
const targetHosts = hasTargetHosts
|
||||
? availableHosts.filter((h) => snippet.hostIds!.includes(h.id))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="relative" onDragOver={onDragOver}>
|
||||
{dropIndicator === "above" && (
|
||||
@@ -774,7 +910,7 @@ function SnippetCard({
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<GripVertical className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/30 group-hover/card:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold">{snippet.name}</span>
|
||||
{snippet.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -782,19 +918,44 @@ function SnippetCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasTargetHosts && (
|
||||
<Zap
|
||||
className="size-3 shrink-0 mt-0.5 text-accent-brand/70"
|
||||
title={t("newUi.sidebar.snippets.hasTargetHosts")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono px-1">
|
||||
{snippet.content}
|
||||
</span>
|
||||
{targetHosts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 px-1">
|
||||
{targetHosts.map((host) => (
|
||||
<span
|
||||
key={host.id}
|
||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 bg-accent-brand/10 text-accent-brand border border-accent-brand/20"
|
||||
>
|
||||
<Server className="size-2.5" />
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 text-xs h-7 gap-1.5"
|
||||
className={`flex-1 text-xs h-7 gap-1.5 ${hasTargetHosts ? "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand" : ""}`}
|
||||
onClick={handleRun}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{t("newUi.sidebar.snippets.run")}
|
||||
{hasTargetHosts ? (
|
||||
<Zap className="size-3" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
{hasTargetHosts
|
||||
? t("newUi.sidebar.snippets.runOnTargets")
|
||||
: t("newUi.sidebar.snippets.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -837,6 +998,207 @@ function SnippetCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportSnippetsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImportDone,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onImportDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setFile(null);
|
||||
setOverwrite(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setFile(e.target.files?.[0] ?? null);
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
const dropped = e.dataTransfer.files?.[0];
|
||||
if (dropped?.name.endsWith(".json")) setFile(dropped);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!file) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
let parsed: SnippetExportData;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
(!Array.isArray(parsed.snippets) && !Array.isArray(parsed.folders))
|
||||
) {
|
||||
toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await importSnippets(parsed, overwrite);
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.importSuccess", {
|
||||
snippets: result.snippetsImported,
|
||||
updated: result.snippetsUpdated,
|
||||
skipped: result.snippetsSkipped,
|
||||
folders: result.foldersImported,
|
||||
}),
|
||||
);
|
||||
onImportDone();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.importFailed"));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.snippets.importTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.importDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-border px-4 py-8 cursor-pointer hover:border-accent-brand/50 transition-colors"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
<Upload className="size-6 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground text-center">
|
||||
{file
|
||||
? t("newUi.sidebar.snippets.importSelectedFile", {
|
||||
name: file.name,
|
||||
})
|
||||
: t("newUi.sidebar.snippets.importDropOrClick")}
|
||||
</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={overwrite}
|
||||
onChange={(e) => setOverwrite(e.target.checked)}
|
||||
className="size-3.5"
|
||||
/>
|
||||
{t("newUi.sidebar.snippets.importOverwrite")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("newUi.sidebar.snippets.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleImport}
|
||||
disabled={!file || importing}
|
||||
>
|
||||
{t("newUi.sidebar.snippets.importStartBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExecutionResult {
|
||||
hostLabel: string;
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ExecutionResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
snippetName,
|
||||
results,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
snippetName: string;
|
||||
results: ExecutionResult[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.snippets.executionResultTitle", {
|
||||
name: snippetName,
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.snippets.executionResultDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 max-h-80 overflow-y-auto">
|
||||
{results.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 border border-border p-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold">{r.hostLabel}</span>
|
||||
<span
|
||||
className={`ml-auto text-[10px] px-1.5 py-0.5 ${r.success ? "bg-green-500/10 text-green-500" : "bg-destructive/10 text-destructive"}`}
|
||||
>
|
||||
{r.success
|
||||
? t("newUi.sidebar.snippets.executionSuccess")
|
||||
: t("newUi.sidebar.snippets.executionFailed")}
|
||||
</span>
|
||||
</div>
|
||||
{(r.output || r.error) && (
|
||||
<pre className="text-xs bg-muted/30 p-2 overflow-x-auto whitespace-pre-wrap font-mono text-muted-foreground max-h-40">
|
||||
{r.error ? r.error : r.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("newUi.sidebar.snippets.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnippetsPanel({
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
@@ -856,6 +1218,13 @@ export function SnippetsPanel({
|
||||
const [editFolder, setEditFolder] = useState<SnippetFolder | null>(null);
|
||||
const [editFolderOpen, setEditFolderOpen] = useState(false);
|
||||
const [shareSnippet, setShareSnippet] = useState<Snippet | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [availableHosts, setAvailableHosts] = useState<SSHHost[]>([]);
|
||||
const [executionResultOpen, setExecutionResultOpen] = useState(false);
|
||||
const [executionResults, setExecutionResults] = useState<ExecutionResult[]>(
|
||||
[],
|
||||
);
|
||||
const [executionSnippetName, setExecutionSnippetName] = useState("");
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
@@ -984,15 +1353,7 @@ export function SnippetsPanel({
|
||||
getSnippets()
|
||||
.then((data) => {
|
||||
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
|
||||
const mapped: Snippet[] = arr.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
content: s.content,
|
||||
folder: s.folder ?? null,
|
||||
order: s.order ?? 0,
|
||||
}));
|
||||
updateSnippets(mapped);
|
||||
updateSnippets(arr.map(mapRawSnippet));
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -1009,6 +1370,10 @@ export function SnippetsPanel({
|
||||
setFolders(mapped);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
getSSHHosts()
|
||||
.then((hosts) => setAvailableHosts(hosts))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function toggleTab(id: string) {
|
||||
@@ -1027,21 +1392,28 @@ export function SnippetsPanel({
|
||||
data: Omit<Snippet, "id" | "order">,
|
||||
id?: number,
|
||||
) {
|
||||
const { hostIds, ...rest } = data;
|
||||
const payload = {
|
||||
...rest,
|
||||
hostFilter: hostIds && hostIds.length > 0 ? hostIds : null,
|
||||
};
|
||||
try {
|
||||
if (id !== undefined) {
|
||||
await apiUpdateSnippet(id, data);
|
||||
await apiUpdateSnippet(id, payload);
|
||||
updateSnippets((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
|
||||
);
|
||||
toast.success(t("newUi.sidebar.snippets.updateSuccess"));
|
||||
} else {
|
||||
const created = await apiCreateSnippet(data);
|
||||
const created = await apiCreateSnippet(payload);
|
||||
updateSnippets((prev) => [
|
||||
...prev,
|
||||
{
|
||||
...data,
|
||||
id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1,
|
||||
order: created.order ?? prev.length,
|
||||
id:
|
||||
(created.id as number) ??
|
||||
Math.max(0, ...prev.map((x) => x.id)) + 1,
|
||||
order: (created.order as number) ?? prev.length,
|
||||
},
|
||||
]);
|
||||
toast.success(t("newUi.sidebar.snippets.createSuccess"));
|
||||
@@ -1125,6 +1497,47 @@ export function SnippetsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const data = await exportSnippets();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "snippets-export.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("newUi.sidebar.snippets.exportSuccess"));
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.snippets.exportFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function reloadData() {
|
||||
const collapsed = getFoldersCollapsed();
|
||||
getSnippets()
|
||||
.then((data) => {
|
||||
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
|
||||
updateSnippets(arr.map(mapRawSnippet));
|
||||
})
|
||||
.catch(() => {});
|
||||
getSnippetFolders()
|
||||
.then((data) => {
|
||||
const arr: RawSnippetFolder[] = Array.isArray(data) ? data : [];
|
||||
const mapped: SnippetFolder[] = arr.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
color: f.color ?? FOLDER_COLORS[0],
|
||||
icon: (f.icon as FolderIconId) ?? "folder",
|
||||
open: !collapsed,
|
||||
}));
|
||||
setFolders(mapped);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function handleDeleteSnippet(id: number) {
|
||||
try {
|
||||
await apiDeleteSnippet(id);
|
||||
@@ -1139,6 +1552,56 @@ export function SnippetsPanel({
|
||||
setSnippetFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleDirectExecute(snippet: Snippet) {
|
||||
const hostIds = snippet.hostIds ?? [];
|
||||
if (hostIds.length === 0) return;
|
||||
|
||||
setExecutionSnippetName(snippet.name);
|
||||
setExecutionResults([]);
|
||||
setExecutionResultOpen(true);
|
||||
|
||||
const results: ExecutionResult[] = await Promise.all(
|
||||
hostIds.map(async (hostId) => {
|
||||
const host = availableHosts.find((h) => h.id === hostId);
|
||||
const hostLabel = host ? host.name || host.ip : String(hostId);
|
||||
try {
|
||||
const result = await apiExecuteSnippet(snippet.id, hostId);
|
||||
return {
|
||||
hostLabel,
|
||||
success: result.success,
|
||||
output: result.output,
|
||||
error: result.error,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
hostLabel,
|
||||
success: false,
|
||||
output: "",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setExecutionResults(results);
|
||||
|
||||
const allOk = results.every((r) => r.success);
|
||||
if (allOk) {
|
||||
toast.success(
|
||||
t("newUi.sidebar.snippets.directRunSuccess", {
|
||||
name: snippet.name,
|
||||
count: results.length,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("newUi.sidebar.snippets.directRunPartialFail", {
|
||||
name: snippet.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = snippetSearch
|
||||
? snippets.filter(
|
||||
(s) =>
|
||||
@@ -1265,6 +1728,24 @@ export function SnippetsPanel({
|
||||
{t("newUi.sidebar.snippets.newFolder")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-1 text-xs min-w-0 overflow-hidden text-muted-foreground hover:text-foreground"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="size-3.5 shrink-0" />
|
||||
{t("newUi.sidebar.snippets.exportBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-1 text-xs min-w-0 overflow-hidden text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<Upload className="size-3.5 shrink-0" />
|
||||
{t("newUi.sidebar.snippets.importBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{(!snippetSearch || uncategorizedSnippets.length > 0) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -1295,10 +1776,12 @@ export function SnippetsPanel({
|
||||
snippet={snippet}
|
||||
selectedTabIds={selectedTabIds}
|
||||
terminalTabs={terminalTabs}
|
||||
activeTabId={activeTabId}
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onDirectExecute={handleDirectExecute}
|
||||
onDragStart={() => handleDragStart(snippet)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, snippet.id)}
|
||||
@@ -1308,6 +1791,7 @@ export function SnippetsPanel({
|
||||
: null
|
||||
}
|
||||
isDragging={draggedSnippet?.id === snippet.id}
|
||||
availableHosts={availableHosts}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -1388,10 +1872,12 @@ export function SnippetsPanel({
|
||||
snippet={snippet}
|
||||
selectedTabIds={selectedTabIds}
|
||||
terminalTabs={terminalTabs}
|
||||
activeTabId={activeTabId}
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onDirectExecute={handleDirectExecute}
|
||||
onDragStart={() => handleDragStart(snippet)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, snippet.id)}
|
||||
@@ -1401,6 +1887,7 @@ export function SnippetsPanel({
|
||||
: null
|
||||
}
|
||||
isDragging={draggedSnippet?.id === snippet.id}
|
||||
availableHosts={availableHosts}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -1426,6 +1913,7 @@ export function SnippetsPanel({
|
||||
folders={folders}
|
||||
snippet={editingSnippet}
|
||||
onSave={handleSaveSnippet}
|
||||
availableHosts={availableHosts}
|
||||
/>
|
||||
<CreateFolderDialog
|
||||
open={createFolderOpen}
|
||||
@@ -1445,6 +1933,17 @@ export function SnippetsPanel({
|
||||
snippet={shareSnippet}
|
||||
onClose={() => setShareSnippet(null)}
|
||||
/>
|
||||
<ImportSnippetsDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImportDone={reloadData}
|
||||
/>
|
||||
<ExecutionResultDialog
|
||||
open={executionResultOpen}
|
||||
onOpenChange={setExecutionResultOpen}
|
||||
snippetName={executionSnippetName}
|
||||
results={executionResults}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -347,6 +347,57 @@ export function SplitScreenPanel({
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.splitScreen.hotkeysTitle")}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{[
|
||||
{
|
||||
keys: ["Ctrl", "Shift", "\\"],
|
||||
label: t("newUi.sidebar.splitScreen.hotkeysSplitRight"),
|
||||
},
|
||||
{
|
||||
keys: ["Ctrl", "Shift", "-"],
|
||||
label: t("newUi.sidebar.splitScreen.hotkeysSplitBelow"),
|
||||
},
|
||||
{
|
||||
keys: ["Alt", "↑↓←→"],
|
||||
label: t("newUi.sidebar.splitScreen.hotkeysNavigatePane"),
|
||||
},
|
||||
{
|
||||
keys: ["Ctrl", "Shift", "]"],
|
||||
label: t("newUi.sidebar.splitScreen.hotkeysNextTab"),
|
||||
},
|
||||
{
|
||||
keys: ["Ctrl", "Shift", "["],
|
||||
label: t("newUi.sidebar.splitScreen.hotkeysPrevTab"),
|
||||
},
|
||||
].map(({ keys, label }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground/70 flex-1 min-w-0 truncate">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{keys.map((k, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex items-center px-1 py-0.5 text-[10px] font-mono bg-muted border border-border/60 text-muted-foreground leading-none"
|
||||
>
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,16 @@ export function SshToolsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastArrow(normalSeq: string, appSeq: string) {
|
||||
for (const tabId of selectedTabIds) {
|
||||
const tab = terminalTabs.find((t) => t.id === tabId);
|
||||
const ref = tab?.terminalRef?.current;
|
||||
if (!ref) continue;
|
||||
const appMode = ref.getApplicationCursorKeysMode?.() ?? false;
|
||||
ref.sendInput?.(appMode ? appSeq : normalSeq);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -86,16 +96,29 @@ export function SshToolsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
if (key === "ArrowUp") {
|
||||
broadcastArrow("\x1B[A", "\x1BOA");
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowDown") {
|
||||
broadcastArrow("\x1B[B", "\x1BOB");
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowRight") {
|
||||
broadcastArrow("\x1B[C", "\x1BOC");
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowLeft") {
|
||||
broadcastArrow("\x1B[D", "\x1BOD");
|
||||
return;
|
||||
}
|
||||
|
||||
const specialMap: Record<string, string> = {
|
||||
Enter: "\r",
|
||||
Backspace: "\x7F",
|
||||
Delete: "\x1B[3~",
|
||||
Tab: "\t",
|
||||
Escape: "\x1B",
|
||||
ArrowUp: "\x1B[A",
|
||||
ArrowDown: "\x1B[B",
|
||||
ArrowRight: "\x1B[C",
|
||||
ArrowLeft: "\x1B[D",
|
||||
Home: "\x1B[H",
|
||||
End: "\x1B[F",
|
||||
PageUp: "\x1B[5~",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user