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:
Luke Gustafson
2026-06-21 15:55:41 -05:00
committed by GitHub
parent 8072b4ede9
commit d1a8dcf3c6
165 changed files with 87244 additions and 60766 deletions
+272 -5
View File
@@ -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,
+47
View File
@@ -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");
}
}
+53
View File
@@ -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");
}
}
+2
View File
@@ -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> {
+40
View File
@@ -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");
}
}
+49
View File
@@ -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");
}
}
+26 -19
View File
@@ -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";
+22
View File
@@ -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> {
+24
View File
@@ -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
View File
@@ -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()) {
+31 -1
View File
@@ -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
+114 -10
View File
@@ -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
View File
@@ -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",
+275 -53
View File
@@ -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>
+11 -5
View File
@@ -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);
+196 -35
View File
@@ -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;
}
+11 -1
View File
@@ -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>
);
}
+222 -9
View File
@@ -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;
}
+51
View File
@@ -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
View File
@@ -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 ─────────────────────────────────────────── */
+11 -3
View File
@@ -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;
}
+5
View File
@@ -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,
};
+88 -1
View File
@@ -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");
});
});
+48 -5
View File
@@ -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");
}
+2
View File
@@ -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;
+6
View File
@@ -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
View File
@@ -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"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+25 -1
View File
@@ -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,
+159 -4
View File
@@ -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>
);
}
+410
View File
@@ -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>
);
}
+75
View File
@@ -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
View File
@@ -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>
);
+233 -44
View File
@@ -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} &middot;{" "}
</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>
+8 -3
View File
@@ -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];
+131 -2
View File
@@ -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>
+113 -26
View File
@@ -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,
};
+33
View File
@@ -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")}
+29 -2
View File
@@ -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>
+506 -45
View File
@@ -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>
+32 -28
View File
@@ -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
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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">
+61
View File
@@ -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>
+40 -8
View File
@@ -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">
+54 -11
View File
@@ -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>
);
}
+5 -4
View File
@@ -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"));
}
};
+584 -9
View File
@@ -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}
+518 -19
View File
@@ -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}
/>
</>
);
}
+51
View File
@@ -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>
);
}
+27 -4
View File
@@ -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