UI upadte, added host system, better flex scaling, improved login.

This commit is contained in:
LukeGus
2025-08-15 01:01:04 -05:00
parent 07367b24b6
commit b854a4956c
11 changed files with 669 additions and 152 deletions

View File

@@ -18,13 +18,19 @@ function getCookie(name: string) {
}, ""); }, "");
} }
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
function App() { function App() {
const [view, setView] = React.useState<string>("homepage") const [view, setView] = useState<string>("homepage")
const [mountedViews, setMountedViews] = React.useState<Set<string>>(new Set(["homepage"])) const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
const [isAuthenticated, setIsAuthenticated] = useState(false) const [isAuthenticated, setIsAuthenticated] = useState(false)
const [username, setUsername] = useState<string | null>(null) const [username, setUsername] = useState<string | null>(null)
const [isAdmin, setIsAdmin] = useState(false) const [isAdmin, setIsAdmin] = useState(false)
const [authLoading, setAuthLoading] = useState(true) const [authLoading, setAuthLoading] = useState(true)
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
useEffect(() => { useEffect(() => {
const checkAuth = () => { const checkAuth = () => {
@@ -71,41 +77,102 @@ function App() {
setView(nextView) setView(nextView)
} }
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
setIsAuthenticated(true)
setIsAdmin(authData.isAdmin)
setUsername(authData.username)
}
return ( return (
<div> <div>
<LeftSidebar {/* Enhanced background overlay - detailed pattern when not authenticated */}
onSelectView={handleSelectView} {!isAuthenticated && !authLoading && (
disabled={!isAuthenticated || authLoading} <div
isAdmin={isAdmin} className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]"
username={username} aria-hidden="true"
> >
{mountedViews.has("homepage") && ( {/* Diagonal stripes pattern */}
<div style={{display: view === "homepage" ? "block" : "none"}}> <div className="absolute inset-0 opacity-20">
<Homepage onSelectView={handleSelectView}/> <div className="absolute inset-0" style={{
backgroundImage: `repeating-linear-gradient(
45deg,
transparent,
transparent 20px,
hsl(var(--primary) / 0.4) 20px,
hsl(var(--primary) / 0.4) 40px
)`
}} />
</div> </div>
)}
{mountedViews.has("ssh_manager") && ( {/* Subtle grid pattern */}
<div style={{display: view === "ssh_manager" ? "block" : "none"}}> <div className="absolute inset-0 opacity-10">
<SSHManager onSelectView={handleSelectView}/> <div className="absolute inset-0" style={{
backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--border) / 0.3) 1px, transparent 1px)`,
backgroundSize: '40px 40px'
}} />
</div> </div>
)}
{mountedViews.has("terminal") && ( {/* Radial gradient overlay */}
<div style={{display: view === "terminal" ? "block" : "none"}}> <div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" />
<Terminal onSelectView={handleSelectView}/> </div>
</div> )}
)}
{mountedViews.has("tunnel") && ( {/* Show login form directly when not authenticated */}
<div style={{display: view === "tunnel" ? "block" : "none"}}> {!isAuthenticated && !authLoading && (
<SSHTunnel onSelectView={handleSelectView}/> <div className="fixed inset-0 flex items-center justify-center z-[10000]">
</div> <Homepage
)} onSelectView={handleSelectView}
{mountedViews.has("config_editor") && ( isAuthenticated={isAuthenticated}
<div style={{display: view === "config_editor" ? "block" : "none"}}> authLoading={authLoading}
<ConfigEditor onSelectView={handleSelectView}/> onAuthSuccess={handleAuthSuccess}
</div> isTopbarOpen={isTopbarOpen}
)} />
<TopNavbar/> </div>
</LeftSidebar> )}
{/* Show sidebar layout only when authenticated */}
{isAuthenticated && (
<LeftSidebar
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin}
username={username}
>
{mountedViews.has("homepage") && (
<div style={{display: view === "homepage" ? "block" : "none"}}>
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{mountedViews.has("ssh_manager") && (
<div style={{display: view === "ssh_manager" ? "block" : "none"}}>
<SSHManager onSelectView={handleSelectView}/>
</div>
)}
{mountedViews.has("terminal") && (
<div style={{display: view === "terminal" ? "block" : "none"}}>
<Terminal onSelectView={handleSelectView}/>
</div>
)}
{mountedViews.has("tunnel") && (
<div style={{display: view === "tunnel" ? "block" : "none"}}>
<SSHTunnel onSelectView={handleSelectView}/>
</div>
)}
{mountedViews.has("config_editor") && (
<div style={{display: view === "config_editor" ? "block" : "none"}}>
<ConfigEditor onSelectView={handleSelectView}/>
</div>
)}
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar>
)}
</div> </div>
) )
} }

View File

@@ -0,0 +1,53 @@
import { Children, ReactElement, cloneElement } from 'react';
import { ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface ButtonGroupProps {
className?: string;
orientation?: 'horizontal' | 'vertical';
children: ReactElement<ButtonProps>[];
}
export const ButtonGroup = ({
className,
orientation = 'horizontal',
children,
}: ButtonGroupProps) => {
const totalButtons = Children.count(children);
const isHorizontal = orientation === 'horizontal';
const isVertical = orientation === 'vertical';
return (
<div
className={cn(
'flex',
{
'flex-col': isVertical,
'w-fit': isVertical,
},
className
)}
>
{Children.map(children, (child, index) => {
const isFirst = index === 0;
const isLast = index === totalButtons - 1;
return cloneElement(child, {
className: cn(
{
'rounded-l-none': isHorizontal && !isFirst,
'rounded-r-none': isHorizontal && !isLast,
'border-l-0': isHorizontal && !isFirst,
'rounded-t-none': isVertical && !isFirst,
'rounded-b-none': isVertical && !isLast,
'border-t-0': isVertical && !isFirst,
},
child.props.className
),
});
})}
</div>
);
};

View File

@@ -0,0 +1,62 @@
import type { ComponentProps, HTMLAttributes } from 'react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
export type StatusProps = ComponentProps<typeof Badge> & {
status: 'online' | 'offline' | 'maintenance' | 'degraded';
};
export const Status = ({ className, status, ...props }: StatusProps) => (
<Badge
className={cn('flex items-center gap-2', 'group', status, className)}
variant="secondary"
{...props}
/>
);
export type StatusIndicatorProps = HTMLAttributes<HTMLSpanElement>;
export const StatusIndicator = ({
className,
...props
}: StatusIndicatorProps) => (
<span className="relative flex h-2 w-2" {...props}>
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
'group-[.online]:bg-emerald-500',
'group-[.offline]:bg-red-500',
'group-[.maintenance]:bg-blue-500',
'group-[.degraded]:bg-amber-500'
)}
/>
<span
className={cn(
'relative inline-flex h-2 w-2 rounded-full',
'group-[.online]:bg-emerald-500',
'group-[.offline]:bg-red-500',
'group-[.maintenance]:bg-blue-500',
'group-[.degraded]:bg-amber-500'
)}
/>
</span>
);
export type StatusLabelProps = HTMLAttributes<HTMLSpanElement>;
export const StatusLabel = ({
className,
children,
...props
}: StatusLabelProps) => (
<span className={cn('text-muted-foreground', className)} {...props}>
{children ?? (
<>
<span className="hidden group-[.online]:block">Online</span>
<span className="hidden group-[.offline]:block">Offline</span>
<span className="hidden group-[.maintenance]:block">Maintenance</span>
<span className="hidden group-[.degraded]:block">Degraded</span>
</>
)}
</span>
);

View File

@@ -242,7 +242,7 @@ function Sidebar({
<div <div
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar-inner" data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border-2 group-data-[variant=floating]:shadow-sm"
> >
{children} {children}
</div> </div>

View File

@@ -6,6 +6,10 @@ import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
interface HomepageProps { interface HomepageProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
isAuthenticated: boolean;
authLoading: boolean;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
isTopbarOpen?: boolean;
} }
function getCookie(name: string) { function getCookie(name: string) {
@@ -26,51 +30,51 @@ const API = axios.create({
baseURL: apiBase, baseURL: apiBase,
}); });
export function Homepage({onSelectView}: HomepageProps): React.ReactElement { export function Homepage({onSelectView, isAuthenticated, authLoading, onAuthSuccess, isTopbarOpen = true}: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [dbError, setDbError] = useState<string | null>(null); const [dbError, setDbError] = useState<string | null>(null);
// Update local state when props change
useEffect(() => { useEffect(() => {
const jwt = getCookie("jwt"); setLoggedIn(isAuthenticated);
}, [isAuthenticated]);
if (jwt) { useEffect(() => {
setAuthLoading(true); if (isAuthenticated) {
Promise.all([ const jwt = getCookie("jwt");
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}), if (jwt) {
API.get("/db-health") Promise.all([
]) API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}),
.then(([meRes]) => { API.get("/db-health")
setLoggedIn(true); ])
setIsAdmin(!!meRes.data.is_admin); .then(([meRes]) => {
setUsername(meRes.data.username || null); setIsAdmin(!!meRes.data.is_admin);
setUserId(meRes.data.userId || null); setUsername(meRes.data.username || null);
setDbError(null); setUserId(meRes.data.userId || null);
})
.catch((err) => {
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null); setDbError(null);
} })
}) .catch((err) => {
.finally(() => setAuthLoading(false)); setIsAdmin(false);
} else { setUsername(null);
setAuthLoading(false); setUserId(null);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null);
}
});
}
} }
}, []); }, [isAuthenticated]);
return ( return (
<div className="w-full min-h-svh grid place-items-center"> <div className={`w-full min-h-svh grid place-items-center relative transition-[padding-top] duration-200 ease-linear ${
<div className="flex flex-row items-center justify-center gap-8"> isTopbarOpen ? 'pt-[66px]' : 'pt-2'
}`}>
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
<HomepageAuth <HomepageAuth
setLoggedIn={setLoggedIn} setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin} setIsAdmin={setIsAdmin}
@@ -80,6 +84,7 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
authLoading={authLoading} authLoading={authLoading}
dbError={dbError} dbError={dbError}
setDbError={setDbError} setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/> />
<HomepageUpdateLog <HomepageUpdateLog
loggedIn={loggedIn} loggedIn={loggedIn}

View File

@@ -121,19 +121,6 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
return null; return null;
} }
if (loading) {
return (
<div className="fixed top-4 right-4 z-50">
<div className="bg-background border rounded-lg p-3 shadow-lg">
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span className="text-sm text-muted-foreground">Loading alerts...</span>
</div>
</div>
</div>
);
}
if (alerts.length === 0) { if (alerts.length === 0) {
return null; return null;
} }
@@ -152,7 +139,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
const hasMultipleAlerts = alerts.length > 1; const hasMultipleAlerts = alerts.length > 1;
return ( return (
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10"> <div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
<div className="relative w-full max-w-2xl mx-4"> <div className="relative w-full max-w-2xl mx-4">
<HomepageAlertCard <HomepageAlertCard
alert={currentAlert} alert={currentAlert}

View File

@@ -33,6 +33,7 @@ interface HomepageAuthProps extends React.ComponentProps<"div"> {
authLoading: boolean; authLoading: boolean;
dbError: string | null; dbError: string | null;
setDbError: (error: string | null) => void; setDbError: (error: string | null) => void;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
} }
export function HomepageAuth({ export function HomepageAuth({
@@ -45,6 +46,7 @@ export function HomepageAuth({
authLoading, authLoading,
dbError, dbError,
setDbError, setDbError,
onAuthSuccess,
...props ...props
}: HomepageAuthProps) { }: HomepageAuthProps) {
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login"); const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
@@ -147,6 +149,14 @@ export function HomepageAuth({
setUsername(meRes.data.username || null); setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null); setUserId(meRes.data.id || null);
setDbError(null); setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({
isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null,
userId: meRes.data.id || null
});
// Update internal state immediately
setInternalLoggedIn(true);
if (tab === "signup") { if (tab === "signup") {
setSignupConfirmPassword(""); setSignupConfirmPassword("");
} }
@@ -255,10 +265,6 @@ export function HomepageAuth({
setError(null); setError(null);
} }
async function resetPassword() {
}
async function handleOIDCLogin() { async function handleOIDCLogin() {
setError(null); setError(null);
setOidcLoading(true); setOidcLoading(true);
@@ -303,6 +309,14 @@ export function HomepageAuth({
setUsername(meRes.data.username || null); setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null); setUserId(meRes.data.id || null);
setDbError(null); setDbError(null);
// Call onAuthSuccess to update App state
onAuthSuccess({
isAdmin: !!meRes.data.is_admin,
username: meRes.data.username || null,
userId: meRes.data.id || null
});
// Update internal state immediately
setInternalLoggedIn(true);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
}) })
.catch(err => { .catch(err => {
@@ -331,12 +345,13 @@ export function HomepageAuth({
return ( return (
<div <div
className={cn( className={cn(
"",
className className
)} )}
{...props} {...props}
> >
<div <div
className={`w-[420px] max-w-full bg-background rounded-xl shadow-lg p-6 flex flex-col ${internalLoggedIn ? '' : 'border border-border'}`}> className={`w-[420px] max-w-full bg-background/95 backdrop-blur-md rounded-xl shadow-2xl p-6 flex flex-col relative ${internalLoggedIn ? '' : 'ring-1 ring-border/50'} focus-within:ring-2 focus-within:ring-primary/50 transition-all duration-200`}>
{dbError && ( {dbError && (
<Alert variant="destructive" className="mb-4"> <Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
@@ -369,16 +384,16 @@ export function HomepageAuth({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{(internalLoggedIn || (authLoading && getCookie("jwt"))) && ( {(internalLoggedIn || getCookie("jwt")) && (
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Alert className="my-2"> <div className="my-2 text-center bg-muted/50 border border-border rounded-lg p-4 w-full">
<AlertTitle>Logged in!</AlertTitle> <h3 className="text-lg font-semibold mb-2">Logged in!</h3>
<AlertDescription> <p className="text-muted-foreground">
You are logged in! Use the sidebar to access all available tools. To get started, You are logged in! Use the sidebar to access all available tools. To get started,
create an SSH Host in the SSH Manager tab. Once created, you can connect to that create an SSH Host in the SSH Manager tab. Once created, you can connect to that
host using the other apps in the sidebar. host using the other apps in the sidebar.
</AlertDescription> </p>
</Alert> </div>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<Button <Button
@@ -607,17 +622,18 @@ export function HomepageAuth({
<p>Enter your new password for <p>Enter your new password for
user: <strong>{localUsername}</strong></p> user: <strong>{localUsername}</strong></p>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="new-password">New Password</Label> <Label htmlFor="new-password">New Password</Label>
<Input <Input
id="new-password" id="new-password"
type="password" type="password"
required required
className="h-11 text-base" className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword} value={newPassword}
onChange={e => setNewPassword(e.target.value)} onChange={e => setNewPassword(e.target.value)}
disabled={resetLoading} disabled={resetLoading}
autoComplete="new-password"
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -626,10 +642,11 @@ export function HomepageAuth({
id="confirm-password" id="confirm-password"
type="password" type="password"
required required
className="h-11 text-base" className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword} value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)} onChange={e => setConfirmPassword(e.target.value)}
disabled={resetLoading} disabled={resetLoading}
autoComplete="new-password"
/> />
</div> </div>
<Button <Button
@@ -719,4 +736,4 @@ export function HomepageAuth({
</div> </div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,80 @@
import React, { useState } from "react";
import {CardTitle} from "@/components/ui/card.tsx";
import {ChevronDown, Folder} from "lucide-react";
import {Button} from "@/components/ui/button.tsx";
import {Host} from "@/ui/Navigation/Hosts/Host.tsx";
import {Separator} from "@/components/ui/separator.tsx";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface FolderCardProps {
folderName: string;
hosts: SSHHost[];
isFirst: boolean;
isLast: boolean;
}
export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
return (
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden" style={{padding: '0', margin: '0'}}>
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
<div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center">
<Folder size={16} strokeWidth={3}/>
</div>
<div className="flex-1 min-w-0">
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
</div>
</div>
<Button
variant="outline"
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
onClick={toggleExpanded}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
</Button>
</div>
{isExpanded && (
<div className="flex flex-col p-2 gap-y-3">
{hosts.map((host, index) => (
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
<Host host={host} />
{index < hosts.length - 1 && (
<div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0" />
</div>
)}
</React.Fragment>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,67 @@
import React from "react";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Button} from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface HostProps {
host: SSHHost;
}
export function Host({ host }: HostProps): React.ReactElement {
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
return (
<div>
<div className="flex items-center gap-2">
<Status status={"online"} className="!bg-transparent !p-0.75 flex-shrink-0">
<StatusIndicator/>
</Status>
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
{host.name || host.ip}
</p>
<ButtonGroup className="flex-shrink-0">
<Button variant="outline" className="!px-2 border-1 border-[#303032]">
<Server/>
</Button>
<Button variant="outline" className="!px-2 border-1 border-[#303032]">
<Terminal/>
</Button>
</ButtonGroup>
</div>
{hasTags && (
<div className="flex flex-wrap items-center gap-2 mt-1">
{tags.map((tag: string) => (
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
<p className="text-sm">{tag}</p>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -46,6 +46,32 @@ import {
TableRow, TableRow,
} from "@/components/ui/table.tsx"; } from "@/components/ui/table.tsx";
import axios from "axios"; import axios from "axios";
import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/SSH/ssh-axios";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableConfigEditor: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface SidebarProps { interface SidebarProps {
onSelectView: (view: string) => void; onSelectView: (view: string) => void;
@@ -119,6 +145,14 @@ export function LeftSidebar({
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true); const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
// SSH Hosts state management
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
const prevHostsRef = React.useRef<SSHHost[]>([]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
React.useEffect(() => { React.useEffect(() => {
if (adminSheetOpen) { if (adminSheetOpen) {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
@@ -147,6 +181,117 @@ export function LeftSidebar({
} }
}, [isAdmin]); }, [isAdmin]);
// SSH Hosts data fetching
const fetchHosts = React.useCallback(async () => {
try {
const newHosts = await getSSHHosts();
const terminalHosts = newHosts.filter(host => host.enableTerminal);
const prevHosts = prevHostsRef.current;
// Create a stable map of existing hosts by ID for comparison
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
const newHostsMap = new Map(terminalHosts.map(h => [h.id, h]));
// Check if there are any meaningful changes
let hasChanges = false;
// Check for new hosts, removed hosts, or changed hosts
if (terminalHosts.length !== prevHosts.length) {
hasChanges = true;
} else {
for (const [id, newHost] of newHostsMap) {
const existingHost = existingHostsMap.get(id);
if (!existingHost) {
hasChanges = true;
break;
}
// Only check fields that affect the display
if (
newHost.name !== existingHost.name ||
newHost.folder !== existingHost.folder ||
newHost.ip !== existingHost.ip ||
newHost.port !== existingHost.port ||
newHost.username !== existingHost.username ||
newHost.pin !== existingHost.pin ||
newHost.enableTerminal !== existingHost.enableTerminal ||
JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags)
) {
hasChanges = true;
break;
}
}
}
if (hasChanges) {
// Use a small delay to batch updates and reduce jittering
setTimeout(() => {
setHosts(terminalHosts);
prevHostsRef.current = terminalHosts;
}, 50);
}
} catch (err: any) {
setHostsError('Failed to load hosts');
}
}, []);
React.useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 10000);
return () => clearInterval(interval);
}, [fetchHosts]);
// Search debouncing
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
// Filter and organize hosts with stable references
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
return hosts.filter(h => {
const searchableText = [
h.name || '',
h.username,
h.ip,
h.folder || '',
...(h.tags || []),
h.authType,
h.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(q);
});
}, [hosts, debouncedSearch]);
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => {
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
if (!map[folder]) map[folder] = [];
map[folder].push(h);
});
return map;
}, [filteredHosts]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === 'No Folder') return -1;
if (b === 'No Folder') return 1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder]);
const getSortedHosts = React.useCallback((arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
}, []);
const handleToggle = async (checked: boolean) => { const handleToggle = async (checked: boolean) => {
if (!isAdmin) { if (!isAdmin) {
return; return;
@@ -344,7 +489,7 @@ export function LeftSidebar({
return ( return (
<div className="min-h-svh"> <div className="min-h-svh">
<SidebarProvider open={isSidebarOpen}> <SidebarProvider open={isSidebarOpen}>
<Sidebar variant="floating"> <Sidebar variant="floating" className="">
<SidebarHeader> <SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white"> <SidebarGroupLabel className="text-lg font-bold text-white">
Termix Termix
@@ -359,48 +504,53 @@ export function LeftSidebar({
</SidebarHeader> </SidebarHeader>
<Separator className="p-0.25"/> <Separator className="p-0.25"/>
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup className="!m-0 !p-0 !-mb-2">
<SidebarGroupContent> <Button className="m-2 flex flex-row font-semibold" variant="outline">
<SidebarMenu> <HardDrive strokeWidth="2.5"/>
<SidebarMenuItem key={"SSH Manager"}> Host Manager
<SidebarMenuButton onClick={() => onSelectView("ssh_manager")} </Button>
disabled={disabled}> </SidebarGroup>
<HardDrive/> <Separator className="p-0.25"/>
<span>SSH Manager</span> <SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
</SidebarMenuButton> {/* Search Input */}
</SidebarMenuItem> <div className="bg-[#131316] rounded-lg">
<div className="ml-5"> <Input
<SidebarMenuItem key={"Terminal"}> value={search}
<SidebarMenuButton onClick={() => onSelectView("terminal")} onChange={e => setSearch(e.target.value)}
disabled={disabled}> placeholder="Search hosts by any info..."
<Computer/> className="w-full h-8 text-sm border-2 border-[#272728] rounded-lg"
<span>Terminal</span> autoComplete="off"
</SidebarMenuButton> />
</SidebarMenuItem> </div>
<SidebarMenuItem key={"Tunnel"}>
<SidebarMenuButton onClick={() => onSelectView("tunnel")} {/* Error Display */}
disabled={disabled}> {hostsError && (
<Server/> <div className="px-4 pb-2">
<span>Tunnel</span> <div className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">
</SidebarMenuButton> {hostsError}
</SidebarMenuItem>
<SidebarMenuItem key={"Config Editor"}>
<SidebarMenuButton onClick={() => onSelectView("config_editor")}
disabled={disabled}>
<File/>
<span>Config Editor</span>
</SidebarMenuButton>
</SidebarMenuItem>
</div> </div>
<SidebarMenuItem key={"Tools"}> </div>
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")} )}
disabled={disabled}>
<Hammer/> {/* Loading State */}
<span>Tools</span> {hostsLoading && (
</SidebarMenuButton> <div className="px-4 pb-2">
</SidebarMenuItem> <div className="text-xs text-muted-foreground text-center">
</SidebarMenu> Loading hosts...
</SidebarGroupContent> </div>
</div>
)}
{/* Hosts by Folder */}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<Separator className="p-0.25 mt-1 mb-1"/> <Separator className="p-0.25 mt-1 mb-1"/>
@@ -908,7 +1058,7 @@ export function LeftSidebar({
{!isSidebarOpen && ( {!isSidebarOpen && (
<div <div
onClick={() => setIsSidebarOpen(true)} onClick={() => setIsSidebarOpen(true)}
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center"> className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
<ChevronRight size={10} /> <ChevronRight size={10} />
</div> </div>
)} )}

View File

@@ -1,19 +1,48 @@
import React from "react"; import React from "react";
import { useSidebar } from "@/components/ui/sidebar"; import {useSidebar} from "@/components/ui/sidebar";
import {Button} from "@/components/ui/button.tsx";
import {ChevronDown, ChevronUpIcon} from "lucide-react";
interface TopNavbarProps {
isTopbarOpen: boolean;
setIsTopbarOpen: (open: boolean) => void;
}
export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): React.ReactElement {
const {state} = useSidebar();
export function TopNavbar(): React.ReactElement {
const { state } = useSidebar();
return ( return (
<div <div>
className="fixed z-10 h-[50px] bg-[#18181b] border border-[#303032] rounded-lg transition-[left] duration-200 ease-linear" <div
style={{ className="fixed z-10 h-[50px] bg-[#18181b] border-2 border-[#303032] rounded-lg transition-all duration-200 ease-linear flex flex-row"
top: "0.5rem", style={{
left: state === "collapsed" ? "calc(1.5rem + 0.5rem)" : "calc(16rem + 0.5rem)", top: isTopbarOpen ? "0.5rem" : "-3rem",
right: "0.5rem" left: state === "collapsed" ? "calc(1.5rem + 0.5rem)" : "calc(16rem + 0.5rem)",
}} right: "0.5rem"
> }}
>
<div className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-3rem)]">
test
</div>
<div className="flex items-center justify-center flex-1">
<Button
variant="outline"
onClick={() => setIsTopbarOpen(false)}
className="w-[28px] h-[28px]"
>
<ChevronUpIcon/>
</Button>
</div>
</div>
{!isTopbarOpen && (
<div
onClick={() => setIsTopbarOpen(true)}
className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md">
<ChevronDown size={10} />
</div>
)}
</div> </div>
) )
} }