v1.6.0 #221

Merged
LukeGus merged 74 commits from dev-1.6.0 into main 2025-09-12 19:42:00 +00:00
6 changed files with 483 additions and 27 deletions
Showing only changes of commit 983cf7383e - Show all commits

View File

@@ -1,25 +1,68 @@
import {StrictMode} from 'react'
import {StrictMode, useEffect, useState, useRef} from 'react'
import {createRoot} from 'react-dom/client'
import { useMediaQuery } from "react-responsive";
import './index.css'
import DesktopApp from './ui/Desktop/DesktopApp.tsx'
import MobileApp from './ui/Mobile/MobileApp.tsx'
import { MobileApp } from './ui/Mobile/MobileApp.tsx'
import {ThemeProvider} from "@/components/theme-provider"
import './i18n/i18n'
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const lastSwitchTime = useRef(0);
const isCurrentlyMobile = useRef(window.innerWidth < 768);
const hasSwitchedOnce = useRef(false);
function RootApp() {
const isMobile = useMediaQuery({ maxWidth: 767 });
useEffect(() => {
let timeoutId: NodeJS.Timeout;
const handleResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const newWidth = window.innerWidth;
const newIsMobile = newWidth < 768;
const now = Date.now();
// If we've already switched once, don't switch again for a very long time
if (hasSwitchedOnce.current && (now - lastSwitchTime.current) < 10000) {
setWidth(newWidth);
return;
}
// Only switch if we're actually crossing the threshold AND enough time has passed
if (newIsMobile !== isCurrentlyMobile.current && (now - lastSwitchTime.current) > 5000) {
lastSwitchTime.current = now;
isCurrentlyMobile.current = newIsMobile;
hasSwitchedOnce.current = true;
setWidth(newWidth);
setIsMobile(newIsMobile);
} else {
setWidth(newWidth);
}
}, 2000); // Even longer debounce
};
window.addEventListener("resize", handleResize);
return (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
{isMobile ? <MobileApp /> : <DesktopApp />}
</ThemeProvider>
);
return () => {
clearTimeout(timeoutId);
window.removeEventListener("resize", handleResize);
};
}, []);
return width;
}
createRoot(document.getElementById("root")!).render(
function RootApp() {
const width = useWindowWidth();
const isMobile = width < 768;
// Use a stable key to prevent unnecessary remounting
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RootApp />
</StrictMode>
);
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<RootApp/>
</ThemeProvider>
</StrictMode>,
)

View File

@@ -1,6 +1,6 @@
import React, {useState, useEffect} from "react"
import {LeftSidebar} from "@/ui/Desktop/Navigation/LeftSidebar.tsx"
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
import {Homepage} from "@/ui/Desktop/Homepage/Homepage.tsx"
import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx"
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx"
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"

View File

@@ -1,7 +1,7 @@
import React, {useEffect, useState} from "react";
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
import {HomepageAuth} from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
import {HomepageUpdateLog} from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
import {HomepageAlertManager} from "@/ui/Desktop/Homepage/HomepageAlertManager.tsx";
import {Button} from "@/components/ui/button.tsx";
import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next";

View File

@@ -1,11 +1,11 @@
import React, {useState, useEffect} from "react";
import {cn} from "../../lib/utils.ts";
import {Button} from "../../components/ui/button.tsx";
import {Input} from "../../components/ui/input.tsx";
import {Label} from "../../components/ui/label.tsx";
import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
import {cn} from "@/lib/utils.ts";
import {Button} from "@/components/ui/button.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
import {useTranslation} from "react-i18next";
import {LanguageSwitcher} from "../../components/LanguageSwitcher";
import {LanguageSwitcher} from "@/components/LanguageSwitcher.tsx";
import {
registerUser,
loginUser,
@@ -18,7 +18,7 @@ import {
completePasswordReset,
getOIDCAuthorizeUrl,
verifyTOTPLogin
} from "../main-axios.ts";
} from "../../main-axios.ts";
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();

View File

@@ -0,0 +1,406 @@
import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react';
import {useXTerm} from 'react-xtermjs';
import {FitAddon} from '@xterm/addon-fit';
import {ClipboardAddon} from '@xterm/addon-clipboard';
import {Unicode11Addon} from '@xterm/addon-unicode11';
import {WebLinksAddon} from '@xterm/addon-web-links';
import {useTranslation} from 'react-i18next';
declare global {
interface Window {
mobileTerminalInitialized?: boolean;
mobileTerminalWebSocket?: WebSocket | null;
mobileTerminalPingInterval?: NodeJS.Timeout | null;
}
}
interface SSHTerminalProps {
hostConfig: any;
isVisible: boolean;
title?: string;
}
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{hostConfig, isVisible},
ref
) {
const {t} = useTranslation();
const {instance: terminal, ref: xtermRef} = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false);
const isVisibleRef = useRef<boolean>(false);
const lastHostConfigRef = useRef<any>(null);
const isInitializedRef = useRef<boolean>(false);
const terminalInstanceRef = useRef<any>(null);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 140;
useEffect(() => {
isVisibleRef.current = isVisible;
}, [isVisible]);
function hardRefresh() {
try {
if (terminal && typeof (terminal as any).refresh === 'function') {
(terminal as any).refresh(0, terminal.rows - 1);
}
} catch (_) {
}
}
function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = {cols, rows};
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
notifyTimerRef.current = setTimeout(() => {
const next = pendingSizeRef.current;
const last = lastSentSizeRef.current;
if (!next) return;
if (last && last.cols === next.cols && last.rows === next.rows) return;
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({type: 'resize', data: next}));
lastSentSizeRef.current = next;
}
}, DEBOUNCE_MS);
}
useImperativeHandle(ref, () => ({
disconnect: () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close();
},
fit: () => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
},
sendInput: (data: string) => {
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
}
},
notifyResize: () => {
try {
const cols = terminal?.cols ?? undefined;
const rows = terminal?.rows ?? undefined;
if (typeof cols === 'number' && typeof rows === 'number') {
scheduleNotify(cols, rows);
hardRefresh();
}
} catch (_) {
}
},
refresh: () => hardRefresh(),
}), [terminal]);
useEffect(() => {
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
function handleWindowResize() {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
ws.addEventListener('open', () => {
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => {
ws.send(JSON.stringify({type: 'input', data}));
});
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}));
}
}, 30000);
window.mobileTerminalPingInterval = pingIntervalRef.current;
});
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'data') terminal.write(msg.data);
else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`);
else if (msg.type === 'connected') {
} else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true;
terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`);
}
} catch (error) {
}
});
ws.addEventListener('close', () => {
if (!wasDisconnectedBySSH.current) {
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
}
});
ws.addEventListener('error', () => {
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
});
}
useEffect(() => {
if (!terminal || !xtermRef.current || !hostConfig) return;
if (window.mobileTerminalInitialized) {
webSocketRef.current = window.mobileTerminalWebSocket || null;
pingIntervalRef.current = window.mobileTerminalPingInterval || null;
terminal.options = {
cursorBlink: true,
cursorStyle: 'bar',
scrollback: 10000,
fontSize: 14,
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
theme: {background: '#18181b', foreground: '#f7f7f7'},
allowTransparency: true,
convertEol: true,
windowsMode: false,
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
fastScrollModifier: 'alt',
fastScrollSensitivity: 5,
allowProposedApi: true,
};
const fitAddon = new FitAddon();
const clipboardAddon = new ClipboardAddon();
const unicode11Addon = new Unicode11Addon();
const webLinksAddon = new WebLinksAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon);
terminal.open(xtermRef.current);
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 100);
});
resizeObserver.observe(xtermRef.current);
setTimeout(() => {
fitAddon.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
setVisible(true);
terminal.focus();
}, 100);
return () => {
resizeObserver.disconnect();
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
};
}
window.mobileTerminalInitialized = true;
isInitializedRef.current = true;
terminalInstanceRef.current = terminal;
lastHostConfigRef.current = hostConfig;
terminal.options = {
cursorBlink: true,
cursorStyle: 'bar',
scrollback: 10000,
fontSize: 14,
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
theme: {background: '#18181b', foreground: '#f7f7f7'},
allowTransparency: true,
convertEol: true,
windowsMode: false,
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
fastScrollModifier: 'alt',
fastScrollSensitivity: 5,
allowProposedApi: true,
};
const fitAddon = new FitAddon();
const clipboardAddon = new ClipboardAddon();
const unicode11Addon = new Unicode11Addon();
const webLinksAddon = new WebLinksAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon);
terminal.open(xtermRef.current);
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 100);
});
resizeObserver.observe(xtermRef.current);
const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve();
readyFonts.then(() => {
setTimeout(() => {
fitAddon.fit();
setTimeout(() => {
fitAddon.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
setVisible(true);
terminal.focus();
}, 0);
const cols = terminal.cols;
const rows = terminal.rows;
const isDev = process.env.NODE_ENV === 'development' &&
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
const wsUrl = isDev
? 'ws://localhost:8082'
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
const ws = new WebSocket(wsUrl);
webSocketRef.current = ws;
window.mobileTerminalWebSocket = ws;
wasDisconnectedBySSH.current = false;
setupWebSocketListeners(ws, cols, rows);
}, 300);
});
return () => {
resizeObserver.disconnect();
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
};
}, [xtermRef, terminal, hostConfig]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
terminal.focus();
}, 0);
}
}, [isVisible, terminal]);
useEffect(() => {
if (!fitAddonRef.current) return;
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
if (terminal && isVisible) {
terminal.focus();
}
}, 0);
}, [isVisible, terminal]);
return (
<div
ref={xtermRef}
className="h-full w-full m-1"
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
onClick={() => {
terminal.focus();
}}
/>
);
});
const style = document.createElement('style');
style.innerHTML = `
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
/* Load NerdFonts locally */
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
.xterm .xterm-viewport::-webkit-scrollbar {
width: 8px;
background: transparent;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(180,180,180,0.7);
border-radius: 4px;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(120,120,120,0.9);
}
.xterm .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(180,180,180,0.7) transparent;
}
.xterm {
font-feature-settings: "liga" 1, "calt" 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.xterm .xterm-screen {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important;
font-variant-ligatures: contextual;
}
.xterm .xterm-screen .xterm-char {
font-feature-settings: "liga" 1, "calt" 1;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
`;
document.head.appendChild(style);

View File

@@ -1,7 +1,14 @@
import {Terminal} from "@/ui/Mobile/Apps/Terminal/Terminal.tsx";
export function MobileApp() {
return (
<div>
<h1>Mobile</h1>
<div className="h-screen w-screen bg-[#18181b]">
<Terminal hostConfig={{
ip: "n/a",
port: 22,
username: "n/a",
password: "n/a"
}} isVisible={true}/>
</div>
)
}