* fix select edit host but not update view (#438)

* fix: Checksum issue with chocolatey

* fix: Remove homebrew old stuff

* Add Korean translation (#439)

Co-authored-by: 송준우 <2484@coreit.co.kr>

* feat: Automate flatpak

* fix: Add imagemagik to electron builder to resolve build error

* fix: Build error with runtime repo flag

* fix: Flatpak runtime error and install freedesktop ver warning

* fix: Flatpak runtime error and install freedesktop ver warning

* feat: Re-add homebrew cask and move scripts to backend

* fix: No sandbox flag issue

* fix: Change name for electron macos cask output

* fix: Sandbox error with Linux

* fix: Remove comming soon for app stores in readme

* Adding Comment at the end of the public_key on the host on deploy (#440)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* -Add New Interface for Credential DB
-Add Credential Name as a comment into the server authorized_key file

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Sudo auto fill password (#441)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Feature Sudo password auto-fill;

* Fix locale json shema;

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Added Italian Language; (#445)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Added Italian Language;

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Auto collapse snippet folders (#448)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* feat: Add collapsable snippets (customizable in user profile)

* Translations (#447)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Added Italian Language;

* Fix translations;

Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations.

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Remove PTY-level keepalive (#449)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation

* fix: finalize adding docker to db

* feat: Add docker management support (local squash)

* Fix RBAC role system bugs and improve UX (#446)

* Fix RBAC role system bugs and improve UX

- Fix user list dropdown selection in host sharing
- Fix role sharing permissions to include role-based access
- Fix translation template interpolation for success messages
- Standardize system roles to admin and user only
- Auto-assign user role to new registrations
- Remove blocking confirmation dialogs in modal contexts
- Add missing i18n keys for common actions
- Fix button type to prevent unintended form submissions

* Enhance RBAC system with UI improvements and security fixes

- Move role assignment to Users tab with per-user role management
- Protect system roles (admin/user) from editing and manual assignment
- Simplify permission system: remove Use level, keep View and Manage
- Hide Update button and Sharing tab for view-only/shared hosts
- Prevent users from sharing hosts with themselves
- Unify table and modal styling across admin panels
- Auto-assign system roles on user registration
- Add permission metadata to host interface

* Add empty state message for role assignment

- Display helpful message when no custom roles available
- Clarify that system roles are auto-assigned
- Add noCustomRolesToAssign translation in English and Chinese

* fix: Prevent credential sharing errors for shared hosts

- Skip credential resolution for shared hosts with credential authentication
  to prevent decryption errors (credentials are encrypted per-user)
- Add warning alert in sharing tab when host uses credential authentication
- Inform users that shared users cannot connect to credential-based hosts
- Add translations for credential sharing warning (EN/ZH)

This prevents authentication failures when sharing hosts configured
with credential authentication while maintaining security by keeping
credentials isolated per user.

* feat: Improve rbac UI and fixes some bugs

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* SOCKS5 support (#452)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* SOCKS5 support

Adding single and chain socks5 proxy support

* fix: cleanup files

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Notes and Expiry fields add (#453)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Notes and Expiry add

* fix: cleanup files

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* fix: ssh host types

* fix: sudo incorrect styling and remove expiration date

* feat: add sudo password and add diagonal bg's

* fix: snippet running on enter key

* fix: base64 decoding

* fix: improve server stats / rbac

* fix: wrap ssh host json export in hosts array

* feat: auto trim host inputs, fix file manager jump hosts, dashboard prevent duplicates, file manager terminal not size updating, improve left sidebar sorting, hide/show tags, add apperance user profile tab, add new host manager tabs.

* feat: improve terminal connection speed

* fix: sqlite constriant errors and support non-root user (nginx perm issue)

* feat: add beta syntax highlighing to terminal

* feat: update imports and improve admin settings user management

* chore: update translations

* chore: update translations

* feat: Complete light mode implementation with semantic theme system (#450)

- Add comprehensive light/dark mode CSS variables with semantic naming
- Implement theme-aware scrollbars using CSS variables
- Add light mode backgrounds: --bg-base, --bg-elevated, --bg-surface, etc.
- Add theme-aware borders: --border-base, --border-panel, --border-subtle
- Add semantic text colors: --foreground-secondary, --foreground-subtle
- Convert oklch colors to hex for better compatibility
- Add theme awareness to CodeMirror editors
- Update dark mode colors for consistency (background, sidebar, card, muted, input)
- Add Tailwind color mappings for semantic classes

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: syntax errors

* chore: updating/match themes and split admin settings

* feat: add translation workflow and remove old translation.json

* fix: translation workflow error

* fix: translation workflow error

* feat: improve translation system and update workflow

* fix: wrong path for translations

* fix: change translation to flat files

* fix: gh rule error

* chore: auto-translate to multiple languages (#458)

* chore: improve organization and made a few styling changes in host manager

* feat: improve terminal stability and split out the host manager

* fix: add unnversiioned files

* chore: migrate all to use the new theme system

* fix: wrong animation line colors

* fix: rbac implementation general issues (local squash)

* fix: remove unneeded files

* feat: add 10 new langs

* chore: update gitnore

* chore: auto-translate to multiple languages (#459)

* fix: improve tunnel system

* fix: properly split tabs, still need to fix up the host manager

* chore: cleanup files (possible RC)

* feat: add norwegian

* chore: auto-translate to multiple languages (#461)

* fix: small qol fixes and began readme update

* fix: run cleanup script

* feat: add docker docs button

* feat: general bug fixes and readme updates

* fix: translations

* chore: auto-translate to multiple languages (#462)

* fix: cleanup files

* fix: test new translation issue and add better server-stats support

* fix: fix translate error

* chore: auto-translate to multiple languages (#463)

* fix: fix translate mismatching text

* chore: auto-translate to multiple languages (#465)

* fix: fix translate mismatching text

* fix: fix translate mismatching text

* chore: auto-translate to multiple languages (#466)

* fix: fix translate mismatching text

* fix: fix translate mismatching text

* fix: fix translate mismatching text

* chore: auto-translate to multiple languages (#467)

* fix: fix translate mismatching text

* chore: auto-translate to multiple languages (#468)

* feat: add to readme, a few qol changes, and improve server stats in general

* chore: auto-translate to multiple languages (#469)

* feat: turned disk uage into graph and fixed issue with termina console

* fix: electron build error and hide icons when shared

* chore: run clean

* fix: general server stats issues, file manager decoding, ui qol

* fix: add dashboard line breaks

* fix: docker console error

* fix: docker console not loading and mismatched stripped background for electron

* fix: docker console not loading

* chore: docker console not loading in docker

* chore: translate readme to chinese

* chore: match package lock to package json

* chore: nginx config issue for dokcer console

* chore: auto-translate to multiple languages (#470)

---------

Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com>
Co-authored-by: junu <bigdwarf_@naver.com>
Co-authored-by: 송준우 <2484@coreit.co.kr>
Co-authored-by: SlimGary <trash.slim@gmail.com>
Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com>
Co-authored-by: Wesley Reid <starhound@lostsouls.org>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Denis <38875137+Medvedinca@users.noreply.github.com>
Co-authored-by: Peet McKinney <68706879+PeetMcK@users.noreply.github.com>
This commit was merged in pull request #471.
This commit is contained in:
Luke Gustafson
2025-12-31 22:20:12 -06:00
committed by GitHub
parent 7139290d14
commit ad86c2040b
225 changed files with 87356 additions and 17706 deletions

View File

@@ -0,0 +1,442 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Shield, Users, Database, Clock } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getAdminOIDCConfig,
getRegistrationAllowed,
getPasswordLoginAllowed,
getUserList,
getUserInfo,
isElectron,
getSessions,
unlinkOIDCFromPasswordAccount,
} from "@/ui/main-axios.ts";
import { RolesTab } from "@/ui/desktop/apps/admin/tabs/RolesTab.tsx";
import { GeneralSettingsTab } from "@/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx";
import { OIDCSettingsTab } from "@/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx";
import { UserManagementTab } from "@/ui/desktop/apps/admin/tabs/UserManagementTab.tsx";
import { SessionManagementTab } from "@/ui/desktop/apps/admin/tabs/SessionManagementTab.tsx";
import { DatabaseSecurityTab } from "@/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx";
import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx";
import { UserEditDialog } from "./dialogs/UserEditDialog.tsx";
import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx";
interface AdminSettingsProps {
isTopbarOpen?: boolean;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
}
export function AdminSettings({
isTopbarOpen = true,
rightSidebarOpen = false,
rightSidebarWidth = 400,
}: AdminSettingsProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { state: sidebarState } = useSidebar();
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
const [oidcConfig, setOidcConfig] = React.useState({
client_id: "",
client_secret: "",
issuer_url: "",
authorization_url: "",
token_url: "",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
userinfo_url: "",
});
const [users, setUsers] = React.useState<
Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
}>
>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false);
const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false);
const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
} | null>(null);
const [currentUser, setCurrentUser] = React.useState<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
} | null>(null);
const [sessions, setSessions] = React.useState<
Array<{
id: string;
userId: string;
username?: string;
deviceType: string;
deviceInfo: string;
createdAt: string;
expiresAt: string;
lastActiveAt: string;
jwtToken: string;
isRevoked?: boolean;
}>
>([]);
const [sessionsLoading, setSessionsLoading] = React.useState(false);
const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false);
const [linkOidcUser, setLinkOidcUser] = React.useState<{
id: string;
username: string;
} | null>(null);
React.useEffect(() => {
if (isElectron()) {
const serverUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (!serverUrl) {
return;
}
}
getAdminOIDCConfig()
.then((res) => {
if (res) setOidcConfig(res);
})
.catch((err) => {
if (!err.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchOidcConfig"));
}
});
getUserInfo()
.then((info) => {
if (info) {
setCurrentUser({
id: info.userId,
username: info.username,
is_admin: info.is_admin,
is_oidc: info.is_oidc,
});
}
})
.catch((err) => {
if (!err?.message?.includes("No server configured")) {
console.warn("Failed to fetch current user info", err);
}
});
fetchUsers();
fetchSessions();
}, []);
React.useEffect(() => {
if (isElectron()) {
const serverUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (!serverUrl) {
return;
}
}
getRegistrationAllowed()
.then((res) => {
if (typeof res?.allowed === "boolean") {
setAllowRegistration(res.allowed);
}
})
.catch((err) => {
if (!err.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchRegistrationStatus"));
}
});
}, []);
React.useEffect(() => {
if (isElectron()) {
const serverUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (!serverUrl) {
return;
}
}
getPasswordLoginAllowed()
.then((res) => {
if (typeof res?.allowed === "boolean") {
setAllowPasswordLogin(res.allowed);
}
})
.catch((err) => {
if (err.code !== "NO_SERVER_CONFIGURED") {
toast.error(t("admin.failedToFetchPasswordLoginStatus"));
}
});
}, []);
const fetchUsers = async () => {
if (isElectron()) {
const serverUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (!serverUrl) {
return;
}
}
setUsersLoading(true);
try {
const response = await getUserList();
setUsers(response.users);
} catch (err) {
if (!err.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchUsers"));
}
} finally {
setUsersLoading(false);
}
};
const handleEditUser = (user: (typeof users)[0]) => {
setSelectedUserForEdit(user);
setUserEditDialogOpen(true);
};
const handleCreateUserSuccess = () => {
fetchUsers();
setCreateUserDialogOpen(false);
};
const handleEditUserSuccess = () => {
fetchUsers();
setUserEditDialogOpen(false);
setSelectedUserForEdit(null);
};
const fetchSessions = async () => {
if (isElectron()) {
const serverUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (!serverUrl) {
return;
}
}
setSessionsLoading(true);
try {
const data = await getSessions();
setSessions(data.sessions || []);
} catch (err) {
if (!err?.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchSessions"));
}
} finally {
setSessionsLoading(false);
}
};
const handleLinkOIDCUser = (user: { id: string; username: string }) => {
setLinkOidcUser(user);
setLinkAccountAlertOpen(true);
};
const handleLinkSuccess = () => {
fetchUsers();
fetchSessions();
};
const handleUnlinkOIDC = async (userId: string, username: string) => {
confirmWithToast(
t("admin.unlinkOIDCDescription", { username }),
async () => {
try {
const result = await unlinkOIDCFromPasswordAccount(userId);
toast.success(
result.message || t("admin.unlinkOIDCSuccess", { username }),
);
fetchUsers();
fetchSessions();
} catch (error: unknown) {
const err = error as {
response?: { data?: { error?: string; code?: string } };
};
toast.error(
err.response?.data?.error || t("admin.failedToUnlinkOIDC"),
);
}
},
"destructive",
);
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = {
marginLeft: leftMarginPx,
marginRight: rightSidebarOpen
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
transition:
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
};
return (
<div
style={wrapperStyle}
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"
>
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">{t("admin.title")}</h1>
</div>
<Separator className="p-0.25 w-full" />
<div className="px-6 py-4 overflow-auto thin-scrollbar">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="mb-4 bg-elevated border-2 border-edge">
<TabsTrigger
value="registration"
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
<Users className="h-4 w-4" />
{t("admin.general")}
</TabsTrigger>
<TabsTrigger
value="oidc"
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
<Shield className="h-4 w-4" />
OIDC
</TabsTrigger>
<TabsTrigger
value="users"
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
<Users className="h-4 w-4" />
{t("admin.users")}
</TabsTrigger>
<TabsTrigger
value="sessions"
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
<Clock className="h-4 w-4" />
Sessions
</TabsTrigger>
<TabsTrigger
value="roles"
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
<Shield className="h-4 w-4" />
{t("rbac.roles.label")}
</TabsTrigger>
<TabsTrigger
value="security"
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
<Database className="h-4 w-4" />
{t("admin.databaseSecurity")}
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
<GeneralSettingsTab
allowRegistration={allowRegistration}
setAllowRegistration={setAllowRegistration}
allowPasswordLogin={allowPasswordLogin}
setAllowPasswordLogin={setAllowPasswordLogin}
oidcConfig={oidcConfig}
/>
</TabsContent>
<TabsContent value="oidc" className="space-y-6">
<OIDCSettingsTab
allowPasswordLogin={allowPasswordLogin}
oidcConfig={oidcConfig}
setOidcConfig={setOidcConfig}
/>
</TabsContent>
<TabsContent value="users" className="space-y-6">
<UserManagementTab
users={users}
usersLoading={usersLoading}
allowPasswordLogin={allowPasswordLogin}
fetchUsers={fetchUsers}
onCreateUser={() => setCreateUserDialogOpen(true)}
onEditUser={handleEditUser}
onLinkOIDCUser={handleLinkOIDCUser}
onUnlinkOIDC={handleUnlinkOIDC}
/>
</TabsContent>
<TabsContent value="sessions" className="space-y-6">
<SessionManagementTab
sessions={sessions}
sessionsLoading={sessionsLoading}
fetchSessions={fetchSessions}
/>
</TabsContent>
<TabsContent value="roles" className="space-y-6">
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<RolesTab />
</div>
</TabsContent>
<TabsContent value="security" className="space-y-6">
<DatabaseSecurityTab currentUser={currentUser} />
</TabsContent>
</Tabs>
</div>
</div>
{/* Dialogs */}
<CreateUserDialog
open={createUserDialogOpen}
onOpenChange={setCreateUserDialogOpen}
onSuccess={handleCreateUserSuccess}
/>
<UserEditDialog
open={userEditDialogOpen}
onOpenChange={setUserEditDialogOpen}
user={selectedUserForEdit}
currentUser={currentUser}
onSuccess={handleEditUserSuccess}
allowPasswordLogin={allowPasswordLogin}
/>
<LinkAccountDialog
open={linkAccountAlertOpen}
onOpenChange={setLinkAccountAlertOpen}
oidcUser={linkOidcUser}
onSuccess={handleLinkSuccess}
/>
</div>
);
}
export default AdminSettings;

View File

@@ -0,0 +1,163 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import { UserPlus, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { registerUser } from "@/ui/main-axios.ts";
interface CreateUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export function CreateUserDialog({
open,
onOpenChange,
onSuccess,
}: CreateUserDialogProps) {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) {
setUsername("");
setPassword("");
setError(null);
}
}, [open]);
const handleCreateUser = async (e?: React.FormEvent) => {
if (e) {
e.preventDefault();
}
if (!username.trim()) {
setError(t("admin.enterUsername"));
return;
}
if (!password.trim()) {
setError(t("admin.enterPassword"));
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters");
return;
}
setLoading(true);
setError(null);
try {
await registerUser(username.trim(), password);
toast.success(
t("admin.userCreatedSuccessfully", { username: username.trim() }),
);
setUsername("");
setPassword("");
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
const errorMessage =
error?.response?.data?.error || t("admin.failedToCreateUser");
setError(errorMessage);
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!loading) {
onOpenChange(newOpen);
}
}}
>
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="w-5 h-5" />
{t("admin.createUser")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("admin.createUserDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreateUser} className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="create-username">{t("admin.username")}</Label>
<Input
id="create-username"
value={username}
onChange={(e) => {
setUsername(e.target.value);
setError(null);
}}
placeholder={t("admin.enterUsername")}
disabled={loading}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-password">{t("common.password")}</Label>
<PasswordInput
id="create-password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError(null);
}}
placeholder={t("admin.enterPassword")}
disabled={loading}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCreateUser();
}
}}
/>
<p className="text-xs text-muted-foreground">
{t("admin.passwordMinLength")}
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</form>
<DialogFooter>
<Button onClick={() => handleCreateUser()} disabled={loading}>
{loading ? t("common.creating") : t("admin.createUser")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,143 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import { Link2 } from "lucide-react";
import { toast } from "sonner";
import { linkOIDCToPasswordAccount } from "@/ui/main-axios.ts";
interface LinkAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
oidcUser: { id: string; username: string } | null;
onSuccess: () => void;
}
export function LinkAccountDialog({
open,
onOpenChange,
oidcUser,
onSuccess,
}: LinkAccountDialogProps) {
const { t } = useTranslation();
const [linkTargetUsername, setLinkTargetUsername] = useState("");
const [linkLoading, setLinkLoading] = useState(false);
useEffect(() => {
if (!open) {
setLinkTargetUsername("");
}
}, [open]);
const handleLinkSubmit = async () => {
if (!oidcUser || !linkTargetUsername.trim()) {
toast.error("Target username is required");
return;
}
setLinkLoading(true);
try {
const result = await linkOIDCToPasswordAccount(
oidcUser.id,
linkTargetUsername.trim(),
);
toast.success(
result.message ||
`OIDC user ${oidcUser.username} linked to ${linkTargetUsername}`,
);
setLinkTargetUsername("");
onSuccess();
onOpenChange(false);
} catch (error: unknown) {
const err = error as {
response?: { data?: { error?: string; code?: string } };
};
toast.error(err.response?.data?.error || "Failed to link accounts");
} finally {
setLinkLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="w-5 h-5" />
{t("admin.linkOidcToPasswordAccount")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("admin.linkOidcToPasswordAccountDescription", {
username: oidcUser?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Alert variant="destructive">
<AlertTitle>{t("admin.linkOidcWarningTitle")}</AlertTitle>
<AlertDescription>
{t("admin.linkOidcWarningDescription")}
<ul className="list-disc list-inside mt-2 space-y-1">
<li>{t("admin.linkOidcActionDeleteUser")}</li>
<li>{t("admin.linkOidcActionAddCapability")}</li>
<li>{t("admin.linkOidcActionDualAuth")}</li>
</ul>
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label
htmlFor="link-target-username"
className="text-base font-semibold text-foreground"
>
{t("admin.linkTargetUsernameLabel")}
</Label>
<Input
id="link-target-username"
value={linkTargetUsername}
onChange={(e) => setLinkTargetUsername(e.target.value)}
placeholder={t("admin.linkTargetUsernamePlaceholder")}
disabled={linkLoading}
onKeyDown={(e) => {
if (e.key === "Enter" && linkTargetUsername.trim()) {
handleLinkSubmit();
}
}}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={linkLoading}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleLinkSubmit}
disabled={linkLoading || !linkTargetUsername.trim()}
variant="destructive"
>
{linkLoading
? t("admin.linkingAccounts")
: t("admin.linkAccountsButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,582 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import {
UserCog,
Trash2,
Plus,
AlertCircle,
Shield,
Key,
Clock,
} from "lucide-react";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getUserRoles,
getRoles,
assignRoleToUser,
removeRoleFromUser,
makeUserAdmin,
removeAdminStatus,
initiatePasswordReset,
revokeAllUserSessions,
deleteUser,
type UserRole,
type Role,
} from "@/ui/main-axios.ts";
interface User {
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
}
interface UserEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user: User | null;
currentUser: { id: string; username: string } | null;
onSuccess: () => void;
allowPasswordLogin: boolean;
}
export function UserEditDialog({
open,
onOpenChange,
user,
currentUser,
onSuccess,
allowPasswordLogin,
}: UserEditDialogProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [adminLoading, setAdminLoading] = useState(false);
const [passwordResetLoading, setPasswordResetLoading] = useState(false);
const [sessionLoading, setSessionLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [rolesLoading, setRolesLoading] = useState(false);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
const [isAdmin, setIsAdmin] = useState(false);
const isCurrentUser = user?.id === currentUser?.id;
useEffect(() => {
if (open && user) {
setIsAdmin(user.is_admin);
loadRoles();
}
}, [open, user]);
const loadRoles = async () => {
if (!user) return;
setRolesLoading(true);
try {
const [rolesResponse, allRolesResponse] = await Promise.all([
getUserRoles(user.id),
getRoles(),
]);
setUserRoles(rolesResponse.roles || []);
setAvailableRoles(allRolesResponse.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
toast.error(t("rbac.failedToLoadRoles"));
} finally {
setRolesLoading(false);
}
};
const handleToggleAdmin = async (checked: boolean) => {
if (!user) return;
if (isCurrentUser) {
toast.error(t("admin.cannotRemoveOwnAdmin"));
return;
}
const userToUpdate = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: checked ? t("admin.makeUserAdmin") : t("admin.removeAdmin"),
description: checked
? t("admin.confirmMakeAdmin", { username: userToUpdate.username })
: t("admin.confirmRemoveAdmin", { username: userToUpdate.username }),
confirmText: checked ? t("admin.makeAdmin") : t("admin.removeAdmin"),
cancelText: t("common.cancel"),
variant: checked ? "default" : "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
setAdminLoading(true);
try {
if (checked) {
await makeUserAdmin(userToUpdate.username);
toast.success(
t("admin.userIsNowAdmin", { username: userToUpdate.username }),
);
} else {
await removeAdminStatus(userToUpdate.username);
toast.success(
t("admin.adminStatusRemoved", { username: userToUpdate.username }),
);
}
setIsAdmin(checked);
onSuccess();
onOpenChange(true);
} catch (error) {
console.error("Failed to toggle admin status:", error);
toast.error(
checked
? t("admin.failedToMakeUserAdmin")
: t("admin.failedToRemoveAdminStatus"),
);
onOpenChange(true);
} finally {
setAdminLoading(false);
}
};
const handlePasswordReset = async () => {
if (!user) return;
const userToReset = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("admin.resetUserPassword"),
description: `${t("admin.passwordResetWarning")} (${userToReset.username})`,
confirmText: t("admin.resetUserPassword"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
setPasswordResetLoading(true);
try {
await initiatePasswordReset(userToReset.username);
toast.success(
t("admin.passwordResetInitiated", { username: userToReset.username }),
);
onSuccess();
onOpenChange(true);
} catch (error) {
console.error("Failed to reset password:", error);
toast.error(t("admin.failedToResetPassword"));
onOpenChange(true);
} finally {
setPasswordResetLoading(false);
}
};
const handleAssignRole = async (roleId: number) => {
if (!user) return;
try {
await assignRoleToUser(user.id, roleId);
toast.success(
t("rbac.roleAssignedSuccessfully", { username: user.username }),
);
await loadRoles();
} catch (error) {
console.error("Failed to assign role:", error);
toast.error(t("rbac.failedToAssignRole"));
}
};
const handleRemoveRole = async (roleId: number) => {
if (!user) return;
const userToUpdate = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("rbac.confirmRemoveRole"),
description: t("rbac.confirmRemoveRoleDescription"),
confirmText: t("common.remove"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
try {
await removeRoleFromUser(userToUpdate.id, roleId);
toast.success(
t("rbac.roleRemovedSuccessfully", { username: userToUpdate.username }),
);
await loadRoles();
onOpenChange(true);
} catch (error) {
console.error("Failed to remove role:", error);
toast.error(t("rbac.failedToRemoveRole"));
onOpenChange(true);
}
};
const handleRevokeAllSessions = async () => {
if (!user) return;
const isRevokingSelf = isCurrentUser;
const userToUpdate = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("admin.revokeAllSessions"),
description: isRevokingSelf
? t("admin.confirmRevokeOwnSessions")
: t("admin.confirmRevokeAllSessions"),
confirmText: t("admin.revoke"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
setSessionLoading(true);
try {
const data = await revokeAllUserSessions(userToUpdate.id);
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
if (isRevokingSelf) {
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
onSuccess();
onOpenChange(true);
}
} catch (error) {
console.error("Failed to revoke sessions:", error);
toast.error(t("admin.failedToRevokeSessions"));
onOpenChange(true);
} finally {
setSessionLoading(false);
}
};
const handleDeleteUser = async () => {
if (!user) return;
if (isCurrentUser) {
toast.error(t("admin.cannotDeleteSelf"));
return;
}
const userToDelete = user;
onOpenChange(false);
const confirmed = await confirmWithToast({
title: t("admin.deleteUserTitle"),
description: t("admin.deleteUser", { username: userToDelete.username }),
confirmText: t("common.delete"),
cancelText: t("common.cancel"),
variant: "destructive",
});
if (!confirmed) {
onOpenChange(true);
return;
}
setDeleteLoading(true);
try {
await deleteUser(userToDelete.username);
toast.success(
t("admin.userDeletedSuccessfully", { username: userToDelete.username }),
);
onSuccess();
} catch (error) {
console.error("Failed to delete user:", error);
toast.error(t("admin.failedToDeleteUser"));
onOpenChange(true);
} finally {
setDeleteLoading(false);
}
};
const getAuthTypeDisplay = (): string => {
if (!user) return "";
if (user.is_oidc && user.password_hash) {
return t("admin.dualAuth");
} else if (user.is_oidc) {
return t("admin.externalOIDC");
} else {
return t("admin.localPassword");
}
};
if (!user) return null;
const showPasswordReset =
allowPasswordLogin && (user.password_hash || !user.is_oidc);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCog className="w-5 h-5" />
{t("admin.manageUser")}: {user.username}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("admin.manageUserDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto thin-scrollbar pr-2">
<div className="grid grid-cols-2 gap-4 p-4 bg-surface rounded-lg border border-edge">
<div>
<Label className="text-muted-foreground text-xs">
{t("admin.username")}
</Label>
<p className="font-medium">{user.username}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">
{t("admin.authType")}
</Label>
<p className="font-medium">{getAuthTypeDisplay()}</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">
{t("admin.adminStatus")}
</Label>
<p className="font-medium">
{isAdmin ? (
<Badge variant="secondary">{t("admin.adminBadge")}</Badge>
) : (
t("admin.regularUser")
)}
</p>
</div>
<div>
<Label className="text-muted-foreground text-xs">
{t("admin.userId")}
</Label>
<p className="font-mono text-xs truncate">{user.id}</p>
</div>
</div>
<Separator />
<div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("admin.adminPrivileges")}
</Label>
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
<div className="flex-1">
<p className="font-medium">{t("admin.administratorRole")}</p>
<p className="text-sm text-muted-foreground">
{t("admin.administratorRoleDescription")}
</p>
</div>
<Switch
checked={isAdmin}
onCheckedChange={handleToggleAdmin}
disabled={isCurrentUser || adminLoading}
/>
</div>
{isCurrentUser && (
<p className="text-xs text-muted-foreground">
{t("admin.cannotModifyOwnAdminStatus")}
</p>
)}
</div>
<Separator />
<div className="space-y-4">
<Label className="text-base font-semibold flex items-center gap-2">
<UserCog className="h-4 w-4" />
{t("rbac.roleManagement")}
</Label>
{rolesLoading ? (
<div className="text-center py-4 text-muted-foreground text-sm">
{t("common.loading")}
</div>
) : (
<>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("rbac.currentRoles")}
</Label>
{userRoles.length === 0 ? (
<p className="text-sm text-muted-foreground italic py-2">
{t("rbac.noRolesAssigned")}
</p>
) : (
<div className="space-y-2">
{userRoles.map((role) => (
<div
key={role.roleId}
className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface"
>
<div>
<p className="font-medium text-sm">
{t(role.roleDisplayName)}
</p>
<p className="text-xs text-muted-foreground">
{role.roleName}
</p>
</div>
<div className="flex items-center gap-2">
{role.isSystem && (
<Badge variant="secondary" className="text-xs">
{t("rbac.systemRole")}
</Badge>
)}
{!role.isSystem && (
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveRole(role.roleId)}
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/30"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{t("rbac.assignNewRole")}
</Label>
<div className="flex flex-wrap gap-2">
{availableRoles
.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
)
.map((role) => (
<Button
key={role.id}
variant="outline"
size="sm"
onClick={() => handleAssignRole(role.id)}
>
<Plus className="h-3 w-3 mr-1" />
{t(role.displayName)}
</Button>
))}
{availableRoles.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
).length === 0 && (
<p className="text-sm text-muted-foreground italic">
{t("rbac.noCustomRolesToAssign")}
</p>
)}
</div>
</div>
</>
)}
</div>
<Separator />
<div className="space-y-3">
<Label className="text-base font-semibold flex items-center gap-2">
<Clock className="h-4 w-4" />
{t("admin.sessionManagement")}
</Label>
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
<div className="flex-1">
<p className="font-medium text-sm">
{t("admin.revokeAllSessions")}
</p>
<p className="text-sm text-muted-foreground">
{t("admin.revokeAllSessionsDescription")}
</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={handleRevokeAllSessions}
disabled={sessionLoading}
>
{sessionLoading ? t("admin.revoking") : t("admin.revoke")}
</Button>
</div>
</div>
<Separator />
<div className="space-y-3">
<Label className="text-base font-semibold text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
{t("admin.dangerZone")}
</Label>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("admin.deleteUserTitle")}</AlertTitle>
<AlertDescription>
{t("admin.deleteUserWarning")}
</AlertDescription>
</Alert>
<Button
variant="destructive"
onClick={handleDeleteUser}
disabled={isCurrentUser || deleteLoading}
className="w-full"
>
<Trash2 className="h-4 w-4 mr-2" />
{deleteLoading
? t("admin.deleting")
: `${t("common.delete")} ${user.username}`}
</Button>
{isCurrentUser && (
<p className="text-xs text-muted-foreground text-center">
{t("admin.cannotDeleteSelf")}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,319 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Download, Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { isElectron } from "@/ui/main-axios.ts";
interface DatabaseSecurityTabProps {
currentUser: {
is_oidc: boolean;
} | null;
}
export function DatabaseSecurityTab({
currentUser,
}: DatabaseSecurityTabProps): React.ReactElement {
const { t } = useTranslation();
const [exportLoading, setExportLoading] = React.useState(false);
const [importLoading, setImportLoading] = React.useState(false);
const [importFile, setImportFile] = React.useState<File | null>(null);
const [exportPassword, setExportPassword] = React.useState("");
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
const [importPassword, setImportPassword] = React.useState("");
const requiresImportPassword = React.useMemo(
() => !currentUser?.is_oidc,
[currentUser?.is_oidc],
);
const handleExportDatabase = async () => {
if (!showPasswordInput) {
setShowPasswordInput(true);
return;
}
if (!exportPassword.trim()) {
toast.error(t("admin.passwordRequired"));
return;
}
setExportLoading(true);
try {
const isDev =
!isElectron() &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "" ||
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1");
const apiUrl = isElectron()
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export`
: isDev
? `http://localhost:30001/database/export`
: `${window.location.protocol}//${window.location.host}/database/export`;
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ password: exportPassword }),
});
if (response.ok) {
const blob = await response.blob();
const contentDisposition = response.headers.get("content-disposition");
const filename =
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
"termix-export.sqlite";
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success(t("admin.databaseExportedSuccessfully"));
setExportPassword("");
setShowPasswordInput(false);
} else {
const error = await response.json();
if (error.code === "PASSWORD_REQUIRED") {
toast.error(t("admin.passwordRequired"));
} else {
toast.error(error.error || t("admin.databaseExportFailed"));
}
}
} catch {
toast.error(t("admin.databaseExportFailed"));
} finally {
setExportLoading(false);
}
};
const handleImportDatabase = async () => {
if (!importFile) {
toast.error(t("admin.pleaseSelectImportFile"));
return;
}
if (requiresImportPassword && !importPassword.trim()) {
toast.error(t("admin.passwordRequired"));
return;
}
setImportLoading(true);
try {
const isDev =
!isElectron() &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "" ||
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1");
const apiUrl = isElectron()
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/import`
: isDev
? `http://localhost:30001/database/import`
: `${window.location.protocol}//${window.location.host}/database/import`;
const formData = new FormData();
formData.append("file", importFile);
if (requiresImportPassword) {
formData.append("password", importPassword);
}
const response = await fetch(apiUrl, {
method: "POST",
credentials: "include",
body: formData,
});
if (response.ok) {
const result = await response.json();
if (result.success) {
const summary = result.summary;
const imported =
summary.sshHostsImported +
summary.sshCredentialsImported +
summary.fileManagerItemsImported +
summary.dismissedAlertsImported +
(summary.settingsImported || 0);
const skipped = summary.skippedItems;
const details = [];
if (summary.sshHostsImported > 0)
details.push(`${summary.sshHostsImported} SSH hosts`);
if (summary.sshCredentialsImported > 0)
details.push(`${summary.sshCredentialsImported} credentials`);
if (summary.fileManagerItemsImported > 0)
details.push(
`${summary.fileManagerItemsImported} file manager items`,
);
if (summary.dismissedAlertsImported > 0)
details.push(`${summary.dismissedAlertsImported} alerts`);
if (summary.settingsImported > 0)
details.push(`${summary.settingsImported} settings`);
toast.success(
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
);
setImportFile(null);
setImportPassword("");
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
toast.error(
`${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`,
);
}
} else {
const error = await response.json();
if (error.code === "PASSWORD_REQUIRED") {
toast.error(t("admin.passwordRequired"));
} else {
toast.error(error.error || t("admin.databaseImportFailed"));
}
}
} catch {
toast.error(t("admin.databaseImportFailed"));
} finally {
setImportLoading(false);
}
};
return (
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<h3 className="text-lg font-semibold">{t("admin.databaseSecurity")}</h3>
<div className="grid gap-3 md:grid-cols-2">
<div className="p-4 border rounded-lg bg-surface">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-blue-500" />
<h4 className="font-semibold">{t("admin.export")}</h4>
</div>
<p className="text-xs text-muted-foreground">
{t("admin.exportDescription")}
</p>
{showPasswordInput && (
<div className="space-y-2">
<Label htmlFor="export-password">Password</Label>
<PasswordInput
id="export-password"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleExportDatabase();
}
}}
/>
</div>
)}
<Button
onClick={handleExportDatabase}
disabled={exportLoading}
className="w-full"
>
{exportLoading
? t("admin.exporting")
: showPasswordInput
? t("admin.confirmExport")
: t("admin.export")}
</Button>
{showPasswordInput && (
<Button
variant="outline"
onClick={() => {
setShowPasswordInput(false);
setExportPassword("");
}}
className="w-full"
>
Cancel
</Button>
)}
</div>
</div>
<div className="p-4 border rounded-lg bg-surface">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-green-500" />
<h4 className="font-semibold">{t("admin.import")}</h4>
</div>
<p className="text-xs text-muted-foreground">
{t("admin.importDescription")}
</p>
<div className="relative inline-block w-full mb-2">
<input
id="import-file-upload"
type="file"
accept=".sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span
className="truncate"
title={importFile?.name || t("admin.pleaseSelectImportFile")}
>
{importFile
? importFile.name
: t("admin.pleaseSelectImportFile")}
</span>
</Button>
</div>
{importFile && requiresImportPassword && (
<div className="space-y-2">
<Label htmlFor="import-password">Password</Label>
<PasswordInput
id="import-password"
value={importPassword}
onChange={(e) => setImportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleImportDatabase();
}
}}
/>
</div>
)}
<Button
onClick={handleImportDatabase}
disabled={
importLoading ||
!importFile ||
(requiresImportPassword && !importPassword.trim())
}
className="w-full"
>
{importLoading ? t("admin.importing") : t("admin.import")}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import React from "react";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
updateRegistrationAllowed,
updatePasswordLoginAllowed,
} from "@/ui/main-axios.ts";
interface GeneralSettingsTabProps {
allowRegistration: boolean;
setAllowRegistration: (value: boolean) => void;
allowPasswordLogin: boolean;
setAllowPasswordLogin: (value: boolean) => void;
oidcConfig: {
client_id: string;
client_secret: string;
issuer_url: string;
authorization_url: string;
token_url: string;
};
}
export function GeneralSettingsTab({
allowRegistration,
setAllowRegistration,
allowPasswordLogin,
setAllowPasswordLogin,
oidcConfig,
}: GeneralSettingsTabProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [regLoading, setRegLoading] = React.useState(false);
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true);
try {
await updateRegistrationAllowed(checked);
setAllowRegistration(checked);
} finally {
setRegLoading(false);
}
};
const handleTogglePasswordLogin = async (checked: boolean) => {
if (!checked) {
const hasOIDCConfigured =
oidcConfig.client_id &&
oidcConfig.client_secret &&
oidcConfig.issuer_url &&
oidcConfig.authorization_url &&
oidcConfig.token_url;
if (!hasOIDCConfigured) {
toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), {
duration: 5000,
});
return;
}
confirmWithToast(
t("admin.confirmDisablePasswordLogin"),
async () => {
setPasswordLoginLoading(true);
try {
await updatePasswordLoginAllowed(checked);
setAllowPasswordLogin(checked);
if (allowRegistration) {
await updateRegistrationAllowed(false);
setAllowRegistration(false);
toast.success(t("admin.passwordLoginAndRegistrationDisabled"));
} else {
toast.success(t("admin.passwordLoginDisabled"));
}
} catch {
toast.error(t("admin.failedToUpdatePasswordLoginStatus"));
} finally {
setPasswordLoginLoading(false);
}
},
"destructive",
);
return;
}
setPasswordLoginLoading(true);
try {
await updatePasswordLoginAllowed(checked);
setAllowPasswordLogin(checked);
} finally {
setPasswordLoginLoading(false);
}
};
return (
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<h3 className="text-lg font-semibold">{t("admin.userRegistration")}</h3>
<label className="flex items-center gap-2">
<Checkbox
checked={allowRegistration}
onCheckedChange={handleToggleRegistration}
disabled={regLoading || !allowPasswordLogin}
/>
{t("admin.allowNewAccountRegistration")}
{!allowPasswordLogin && (
<span className="text-xs text-muted-foreground">
({t("admin.requiresPasswordLogin")})
</span>
)}
</label>
<label className="flex items-center gap-2">
<Checkbox
checked={allowPasswordLogin}
onCheckedChange={handleTogglePasswordLogin}
disabled={passwordLoginLoading}
/>
{t("admin.allowPasswordLogin")}
</label>
</div>
);
}

View File

@@ -0,0 +1,319 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { updateOIDCConfig, disableOIDCConfig } from "@/ui/main-axios.ts";
interface OIDCSettingsTabProps {
allowPasswordLogin: boolean;
oidcConfig: {
client_id: string;
client_secret: string;
issuer_url: string;
authorization_url: string;
token_url: string;
identifier_path: string;
name_path: string;
scopes: string;
userinfo_url: string;
};
setOidcConfig: React.Dispatch<
React.SetStateAction<{
client_id: string;
client_secret: string;
issuer_url: string;
authorization_url: string;
token_url: string;
identifier_path: string;
name_path: string;
scopes: string;
userinfo_url: string;
}>
>;
}
export function OIDCSettingsTab({
allowPasswordLogin,
oidcConfig,
setOidcConfig,
}: OIDCSettingsTabProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setOidcLoading(true);
setOidcError(null);
const required = [
"client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
];
const missing = required.filter(
(f) => !oidcConfig[f as keyof typeof oidcConfig],
);
if (missing.length > 0) {
setOidcError(
t("admin.missingRequiredFields", { fields: missing.join(", ") }),
);
setOidcLoading(false);
return;
}
try {
await updateOIDCConfig(oidcConfig);
toast.success(t("admin.oidcConfigurationUpdated"));
} catch (err: unknown) {
setOidcError(
(err as { response?: { data?: { error?: string } } })?.response?.data
?.error || t("admin.failedToUpdateOidcConfig"),
);
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig((prev) => ({ ...prev, [field]: value }));
};
const handleResetConfig = async () => {
if (!allowPasswordLogin) {
confirmWithToast(
t("admin.confirmDisableOIDCWarning"),
async () => {
const emptyConfig = {
client_id: "",
client_secret: "",
issuer_url: "",
authorization_url: "",
token_url: "",
identifier_path: "",
name_path: "",
scopes: "",
userinfo_url: "",
};
setOidcConfig(emptyConfig);
setOidcError(null);
setOidcLoading(true);
try {
await disableOIDCConfig();
toast.success(t("admin.oidcConfigurationDisabled"));
} catch (err: unknown) {
setOidcError(
(
err as {
response?: { data?: { error?: string } };
}
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
);
} finally {
setOidcLoading(false);
}
},
"destructive",
);
return;
}
const emptyConfig = {
client_id: "",
client_secret: "",
issuer_url: "",
authorization_url: "",
token_url: "",
identifier_path: "",
name_path: "",
scopes: "",
userinfo_url: "",
};
setOidcConfig(emptyConfig);
setOidcError(null);
setOidcLoading(true);
try {
await disableOIDCConfig();
toast.success(t("admin.oidcConfigurationDisabled"));
} catch (err: unknown) {
setOidcError(
(
err as {
response?: { data?: { error?: string } };
}
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
);
} finally {
setOidcLoading(false);
}
};
return (
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-3">
<h3 className="text-lg font-semibold">
{t("admin.externalAuthentication")}
</h3>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t("admin.configureExternalProvider")}
</p>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() => window.open("https://docs.termix.site/oidc", "_blank")}
>
{t("common.documentation")}
</Button>
</div>
{!allowPasswordLogin && (
<Alert variant="destructive">
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
<AlertDescription>{t("admin.oidcRequiredWarning")}</AlertDescription>
</Alert>
)}
{oidcError && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">{t("admin.clientId")}</Label>
<Input
id="client_id"
value={oidcConfig.client_id}
onChange={(e) =>
handleOIDCConfigChange("client_id", e.target.value)
}
placeholder={t("placeholders.clientId")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">{t("admin.clientSecret")}</Label>
<PasswordInput
id="client_secret"
value={oidcConfig.client_secret}
onChange={(e) =>
handleOIDCConfigChange("client_secret", e.target.value)
}
placeholder={t("placeholders.clientSecret")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">
{t("admin.authorizationUrl")}
</Label>
<Input
id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) =>
handleOIDCConfigChange("authorization_url", e.target.value)
}
placeholder={t("placeholders.authUrl")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">{t("admin.issuerUrl")}</Label>
<Input
id="issuer_url"
value={oidcConfig.issuer_url}
onChange={(e) =>
handleOIDCConfigChange("issuer_url", e.target.value)
}
placeholder={t("placeholders.redirectUrl")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">{t("admin.tokenUrl")}</Label>
<Input
id="token_url"
value={oidcConfig.token_url}
onChange={(e) =>
handleOIDCConfigChange("token_url", e.target.value)
}
placeholder={t("placeholders.tokenUrl")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">
{t("admin.userIdentifierPath")}
</Label>
<Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) =>
handleOIDCConfigChange("identifier_path", e.target.value)
}
placeholder={t("placeholders.userIdField")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">{t("admin.displayNamePath")}</Label>
<Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) =>
handleOIDCConfigChange("name_path", e.target.value)
}
placeholder={t("placeholders.usernameField")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">{t("admin.scopes")}</Label>
<Input
id="scopes"
value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange("scopes", e.target.value)}
placeholder={t("placeholders.scopes")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="userinfo_url">{t("admin.overrideUserInfoUrl")}</Label>
<Input
id="userinfo_url"
value={oidcConfig.userinfo_url}
onChange={(e) =>
handleOIDCConfigChange("userinfo_url", e.target.value)
}
placeholder="https://your-provider.com/application/o/userinfo/"
/>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1" disabled={oidcLoading}>
{oidcLoading ? t("admin.saving") : t("admin.saveConfiguration")}
</Button>
<Button
type="button"
variant="outline"
onClick={handleResetConfig}
disabled={oidcLoading}
>
{t("admin.reset")}
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,300 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import { Shield, Plus, Edit, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getRoles,
createRole,
updateRole,
deleteRole,
type Role,
} from "@/ui/main-axios.ts";
export function RolesTab(): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [roles, setRoles] = React.useState<Role[]>([]);
const [loading, setLoading] = React.useState(false);
const [roleDialogOpen, setRoleDialogOpen] = React.useState(false);
const [editingRole, setEditingRole] = React.useState<Role | null>(null);
const [roleName, setRoleName] = React.useState("");
const [roleDisplayName, setRoleDisplayName] = React.useState("");
const [roleDescription, setRoleDescription] = React.useState("");
const loadRoles = React.useCallback(async () => {
setLoading(true);
try {
const response = await getRoles();
setRoles(response.roles || []);
} catch (error) {
toast.error(t("rbac.failedToLoadRoles"));
console.error("Failed to load roles:", error);
setRoles([]);
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
loadRoles();
}, [loadRoles]);
const handleCreateRole = () => {
setEditingRole(null);
setRoleName("");
setRoleDisplayName("");
setRoleDescription("");
setRoleDialogOpen(true);
};
const handleEditRole = (role: Role) => {
setEditingRole(role);
setRoleName(role.name);
setRoleDisplayName(role.displayName);
setRoleDescription(role.description || "");
setRoleDialogOpen(true);
};
const handleSaveRole = async () => {
if (!roleDisplayName.trim()) {
toast.error(t("rbac.roleDisplayNameRequired"));
return;
}
if (!editingRole && !roleName.trim()) {
toast.error(t("rbac.roleNameRequired"));
return;
}
try {
if (editingRole) {
await updateRole(editingRole.id, {
displayName: roleDisplayName,
description: roleDescription || null,
});
toast.success(t("rbac.roleUpdatedSuccessfully"));
} else {
await createRole({
name: roleName,
displayName: roleDisplayName,
description: roleDescription || null,
});
toast.success(t("rbac.roleCreatedSuccessfully"));
}
setRoleDialogOpen(false);
loadRoles();
} catch (error) {
toast.error(t("rbac.failedToSaveRole"));
}
};
const handleDeleteRole = async (role: Role) => {
const confirmed = await confirmWithToast({
title: t("rbac.confirmDeleteRole"),
description: t("rbac.confirmDeleteRoleDescription", {
name: role.displayName,
}),
confirmText: t("common.delete"),
cancelText: t("common.cancel"),
});
if (!confirmed) return;
try {
await deleteRole(role.id);
toast.success(t("rbac.roleDeletedSuccessfully"));
loadRoles();
} catch (error) {
toast.error(t("rbac.failedToDeleteRole"));
}
};
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Shield className="h-5 w-5" />
{t("rbac.roleManagement")}
</h3>
<Button onClick={handleCreateRole}>
<Plus className="h-4 w-4 mr-2" />
{t("rbac.createRole")}
</Button>
</div>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() => window.open("https://docs.termix.site/rbac", "_blank")}
>
{t("common.documentation")}
</Button>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("rbac.roleName")}</TableHead>
<TableHead>{t("rbac.displayName")}</TableHead>
<TableHead>{t("rbac.description")}</TableHead>
<TableHead>{t("rbac.type")}</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground"
>
{t("common.loading")}
</TableCell>
</TableRow>
) : roles.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground"
>
{t("rbac.noRoles")}
</TableCell>
</TableRow>
) : (
roles.map((role) => (
<TableRow key={role.id}>
<TableCell className="font-mono">{role.name}</TableCell>
<TableCell>{t(role.displayName)}</TableCell>
<TableCell className="max-w-xs truncate">
{role.description || "-"}
</TableCell>
<TableCell>
{role.isSystem ? (
<Badge variant="secondary">{t("rbac.systemRole")}</Badge>
) : (
<Badge variant="outline">{t("rbac.customRole")}</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{!role.isSystem && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditRole(role)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteRole(role)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Create/Edit Role Dialog */}
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle>
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}
</DialogTitle>
<DialogDescription>
{editingRole
? t("rbac.editRoleDescription")
: t("rbac.createRoleDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{!editingRole && (
<div className="space-y-2">
<Label htmlFor="role-name">{t("rbac.roleName")}</Label>
<Input
id="role-name"
value={roleName}
onChange={(e) => setRoleName(e.target.value.toLowerCase())}
placeholder="developer"
disabled={!!editingRole}
/>
<p className="text-xs text-muted-foreground">
{t("rbac.roleNameHint")}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="role-display-name">{t("rbac.displayName")}</Label>
<Input
id="role-display-name"
value={roleDisplayName}
onChange={(e) => setRoleDisplayName(e.target.value)}
placeholder={t("rbac.displayNamePlaceholder")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="role-description">{t("rbac.description")}</Label>
<Textarea
id="role-description"
value={roleDescription}
onChange={(e) => setRoleDescription(e.target.value)}
placeholder={t("rbac.descriptionPlaceholder")}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSaveRole}>
{editingRole ? t("common.save") : t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Monitor, Smartphone, Globe, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getCookie,
revokeSession,
revokeAllUserSessions,
} from "@/ui/main-axios.ts";
interface Session {
id: string;
userId: string;
username?: string;
deviceType: string;
deviceInfo: string;
createdAt: string;
expiresAt: string;
lastActiveAt: string;
jwtToken: string;
isRevoked?: boolean;
}
interface SessionManagementTabProps {
sessions: Session[];
sessionsLoading: boolean;
fetchSessions: () => void;
}
export function SessionManagementTab({
sessions,
sessionsLoading,
fetchSessions,
}: SessionManagementTabProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const handleRevokeSession = async (sessionId: string) => {
const currentJWT = getCookie("jwt");
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
const isCurrentSession = currentSession?.id === sessionId;
confirmWithToast(
t("admin.confirmRevokeSession"),
async () => {
try {
await revokeSession(sessionId);
toast.success(t("admin.sessionRevokedSuccessfully"));
if (isCurrentSession) {
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
fetchSessions();
}
} catch {
toast.error(t("admin.failedToRevokeSession"));
}
},
"destructive",
);
};
const handleRevokeAllUserSessions = async (userId: string) => {
confirmWithToast(
t("admin.confirmRevokeAllSessions"),
async () => {
try {
const data = await revokeAllUserSessions(userId);
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
fetchSessions();
} catch {
toast.error(t("admin.failedToRevokeSessions"));
}
},
"destructive",
);
};
const formatDate = (date: Date) =>
date.toLocaleDateString() +
" " +
date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{t("admin.sessionManagement")}
</h3>
<Button
onClick={fetchSessions}
disabled={sessionsLoading}
variant="outline"
size="sm"
>
{sessionsLoading ? t("admin.loading") : t("admin.refresh")}
</Button>
</div>
{sessionsLoading ? (
<div className="text-center py-8 text-muted-foreground">
{t("admin.loadingSessions")}
</div>
) : sessions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("admin.noActiveSessions")}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.device")}</TableHead>
<TableHead>{t("admin.user")}</TableHead>
<TableHead>{t("admin.created")}</TableHead>
<TableHead>{t("admin.lastActive")}</TableHead>
<TableHead>{t("admin.expires")}</TableHead>
<TableHead>{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => {
const DeviceIcon =
session.deviceType === "desktop"
? Monitor
: session.deviceType === "mobile"
? Smartphone
: Globe;
const createdDate = new Date(session.createdAt);
const lastActiveDate = new Date(session.lastActiveAt);
const expiresDate = new Date(session.expiresAt);
return (
<TableRow
key={session.id}
className={session.isRevoked ? "opacity-50" : undefined}
>
<TableCell className="px-4">
<div className="flex items-center gap-2">
<DeviceIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium text-sm">
{session.deviceInfo}
</span>
{session.isRevoked && (
<span className="text-xs text-red-600">
{t("admin.revoked")}
</span>
)}
</div>
</div>
</TableCell>
<TableCell className="px-4">
{session.username || session.userId}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(createdDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(lastActiveDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(expiresDate)}
</TableCell>
<TableCell className="px-4">
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleRevokeSession(session.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={session.isRevoked}
>
<Trash2 className="h-4 w-4" />
</Button>
{session.username && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeAllUserSessions(session.userId)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
title={t("admin.revokeAllUserSessionsTitle")}
>
{t("admin.revokeAll")}
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { UserPlus, Edit, Trash2, Link2, Unlink } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { deleteUser } from "@/ui/main-axios.ts";
interface User {
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
}
interface UserManagementTabProps {
users: User[];
usersLoading: boolean;
allowPasswordLogin: boolean;
fetchUsers: () => void;
onCreateUser: () => void;
onEditUser: (user: User) => void;
onLinkOIDCUser: (user: { id: string; username: string }) => void;
onUnlinkOIDC: (userId: string, username: string) => void;
}
export function UserManagementTab({
users,
usersLoading,
allowPasswordLogin,
fetchUsers,
onCreateUser,
onEditUser,
onLinkOIDCUser,
onUnlinkOIDC,
}: UserManagementTabProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const getAuthTypeDisplay = (user: User): string => {
if (user.is_oidc && user.password_hash) {
return t("admin.dualAuth");
} else if (user.is_oidc) {
return t("admin.externalOIDC");
} else {
return t("admin.localPassword");
}
};
const handleDeleteUserQuick = async (username: string) => {
confirmWithToast(
t("admin.deleteUser", { username }),
async () => {
try {
await deleteUser(username);
toast.success(t("admin.userDeletedSuccessfully", { username }));
fetchUsers();
} catch {
toast.error(t("admin.failedToDeleteUser"));
}
},
"destructive",
);
};
return (
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t("admin.userManagement")}</h3>
<div className="flex gap-2">
{allowPasswordLogin && (
<Button onClick={onCreateUser} size="sm">
<UserPlus className="h-4 w-4 mr-2" />
{t("admin.createUser")}
</Button>
)}
<Button
onClick={fetchUsers}
disabled={usersLoading}
variant="outline"
size="sm"
>
{usersLoading ? t("admin.loading") : t("admin.refresh")}
</Button>
</div>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">
{t("admin.loadingUsers")}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.username")}</TableHead>
<TableHead>{t("admin.authType")}</TableHead>
<TableHead>{t("admin.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.username}
{user.is_admin && (
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{t("admin.adminBadge")}
</span>
)}
</TableCell>
<TableCell>{getAuthTypeDisplay(user)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onEditUser(user)}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
title={t("admin.manageUser")}
>
<Edit className="h-4 w-4" />
</Button>
{user.is_oidc && !user.password_hash && (
<Button
variant="ghost"
size="sm"
onClick={() =>
onLinkOIDCUser({
id: user.id,
username: user.username,
})
}
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
title="Link to password account"
>
<Link2 className="h-4 w-4" />
</Button>
)}
{user.is_oidc && user.password_hash && (
<Button
variant="ghost"
size="sm"
onClick={() => onUnlinkOIDC(user.id, user.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
title="Unlink OIDC (keep password only)"
>
<Unlink className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteUserQuick(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
}

View File

@@ -19,6 +19,8 @@ import {
FolderOpen,
Pencil,
EllipsisVertical,
ArrowDownUp,
Container,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { BiMoney, BiSupport } from "react-icons/bi";
@@ -27,6 +29,7 @@ import { GrUpdate } from "react-icons/gr";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
import type { RecentActivityItem } from "@/ui/main-axios.ts";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -52,8 +55,10 @@ interface SSHHost {
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
enableDocker: boolean;
defaultPath: string;
tunnelConnections: unknown[];
statsConfig?: string;
createdAt: string;
updatedAt: string;
}
@@ -88,7 +93,10 @@ export function CommandPalette({
const handleAddHost = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_host" });
updateTab(sshManagerTab.id, {
initialTab: "add_host",
hostConfig: undefined,
});
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
@@ -104,7 +112,10 @@ export function CommandPalette({
const handleAddCredential = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
updateTab(sshManagerTab.id, {
initialTab: "add_credential",
hostConfig: undefined,
});
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
@@ -212,7 +223,23 @@ export function CommandPalette({
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "server", title, hostConfig: host });
addTab({ type: "server_stats", title, hostConfig: host });
setIsOpen(false);
};
const handleHostTunnelClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "tunnel", title, hostConfig: host });
setIsOpen(false);
};
const handleHostDockerClick = (host: SSHHost) => {
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({ type: "docker", title, hostConfig: host });
setIsOpen(false);
};
@@ -239,7 +266,7 @@ export function CommandPalette({
>
<Command
className={cn(
"w-3/4 max-w-2xl max-h-[60vh] rounded-lg border-2 border-dark-border shadow-md flex flex-col",
"w-3/4 max-w-2xl max-h-[60vh] rounded-lg border-2 border-edge shadow-md flex flex-col bg-elevated",
"transition-all duration-200 ease-out",
!isOpen && "scale-95 opacity-0",
)}
@@ -251,7 +278,7 @@ export function CommandPalette({
/>
<CommandList
key={recentActivity.length}
className="w-full h-auto flex-grow overflow-y-auto"
className="w-full h-auto flex-grow overflow-y-auto thin-scrollbar"
style={{ maxHeight: "inherit" }}
>
{recentActivity.length > 0 && (
@@ -301,6 +328,31 @@ export function CommandPalette({
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
let shouldShowMetrics = true;
try {
const statsConfig = host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
shouldShowMetrics = statsConfig.metricsEnabled !== false;
} catch {
shouldShowMetrics = true;
}
let hasTunnelConnections = false;
try {
const tunnelConnections = Array.isArray(
host.tunnelConnections,
)
? host.tunnelConnections
: JSON.parse(host.tunnelConnections as string);
hasTunnelConnections =
Array.isArray(tunnelConnections) &&
tunnelConnections.length > 0;
} catch {
hasTunnelConnections = false;
}
return (
<CommandItem
key={`host-${index}-${host.id}`}
@@ -324,7 +376,7 @@ export function CommandPalette({
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="!px-2 h-7 border-1 border-dark-border"
className="!px-2 h-7 border-1 border-edge"
onClick={(e) => e.stopPropagation()}
>
<EllipsisVertical className="h-3 w-3" />
@@ -333,43 +385,73 @@ export function CommandPalette({
<DropdownMenuContent
align="end"
side="right"
className="w-56 bg-dark-bg border-dark-border text-white"
className="w-56 bg-canvas border-edge text-foreground"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostServerDetailsClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Server className="h-4 w-4" />
<span className="flex-1">
{t("commandPalette.openServerDetails")}
</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostFileManagerClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<FolderOpen className="h-4 w-4" />
<span className="flex-1">
{t("commandPalette.openFileManager")}
</span>
</DropdownMenuItem>
{shouldShowMetrics && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostServerDetailsClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<Server className="h-4 w-4" />
<span className="flex-1">
{t("hosts.openServerStats")}
</span>
</DropdownMenuItem>
)}
{host.enableFileManager && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostFileManagerClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<FolderOpen className="h-4 w-4" />
<span className="flex-1">
{t("hosts.openFileManager")}
</span>
</DropdownMenuItem>
)}
{host.enableTunnel && hasTunnelConnections && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostTunnelClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<ArrowDownUp className="h-4 w-4" />
<span className="flex-1">
{t("hosts.openTunnels")}
</span>
</DropdownMenuItem>
)}
{host.enableDocker && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostDockerClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<Container className="h-4 w-4" />
<span className="flex-1">
{t("hosts.openDocker")}
</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleHostEditClick(host);
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<Pencil className="h-4 w-4" />
<span className="flex-1">
{t("commandPalette.edit")}
</span>
<span className="flex-1">{t("common.edit")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -400,7 +482,7 @@ export function CommandPalette({
</CommandItem>
</CommandGroup>
</CommandList>
<div className="border-t border-dark-border px-4 py-2 bg-dark-hover/50 flex items-center justify-between text-xs text-muted-foreground">
<div className="border-t border-edge px-4 py-2 bg-hover/50 flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span>{t("commandPalette.press")}</span>
<KbdGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,9 @@ import {
Loader2,
Terminal,
FolderOpen,
Activity,
Container,
ArrowDownUp,
} from "lucide-react";
import { Status } from "@/components/ui/shadcn-io/status";
import { BsLightning } from "react-icons/bs";
@@ -297,14 +300,48 @@ export function Dashboard({
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "server_stats") {
addTab({
type: "server_stats",
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "tunnel") {
addTab({
type: "tunnel",
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "docker") {
addTab({
type: "docker",
title: item.hostName,
hostConfig: host,
});
}
});
};
const handleServerStatClick = (serverId: number, serverName: string) => {
getSSHHosts().then((hosts) => {
const host = hosts.find((h: { id: number }) => h.id === serverId);
if (!host) return;
addTab({
type: "server_stats",
title: serverName,
hostConfig: host,
});
});
};
const handleAddHost = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_host" });
updateTab(sshManagerTab.id, {
initialTab: "add_host",
hostConfig: undefined,
});
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
@@ -319,7 +356,10 @@ export function Dashboard({
const handleAddCredential = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
updateTab(sshManagerTab.id, {
initialTab: "add_credential",
hostConfig: undefined,
});
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
@@ -369,7 +409,7 @@ export function Dashboard({
</div>
) : (
<div
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex min-w-0"
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden flex min-w-0"
style={{
marginLeft: leftMarginPx,
marginRight: rightSidebarOpen
@@ -384,7 +424,7 @@ export function Dashboard({
>
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
<div className="text-2xl text-white font-semibold shrink-0">
<div className="text-2xl text-foreground font-semibold shrink-0">
{t("dashboard.title")}
</div>
<div className="flex flex-row gap-3 flex-wrap min-w-0">
@@ -394,7 +434,7 @@ export function Dashboard({
</p>
</div>
<Button
className="font-semibold shrink-0"
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open(
@@ -406,7 +446,7 @@ export function Dashboard({
{t("dashboard.github")}
</Button>
<Button
className="font-semibold shrink-0"
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open(
@@ -418,7 +458,7 @@ export function Dashboard({
{t("dashboard.support")}
</Button>
<Button
className="font-semibold shrink-0"
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open(
@@ -430,7 +470,7 @@ export function Dashboard({
{t("dashboard.discord")}
</Button>
<Button
className="font-semibold shrink-0"
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open("https://github.com/sponsors/LukeGus", "_blank")
@@ -445,20 +485,16 @@ export function Dashboard({
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0 min-w-0">
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<Server className="mr-3" />
{t("dashboard.serverOverview")}
</p>
<div className="bg-dark-bg w-full h-auto border-2 border-dark-border rounded-md px-3 py-3">
<div className="bg-canvas w-full h-auto border-2 border-edge rounded-md px-3 py-3">
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<History
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<History size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.version")}
</p>
@@ -471,7 +507,7 @@ export function Dashboard({
<Button
variant="outline"
size="sm"
className={`ml-2 text-sm border-1 border-dark-border ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
>
{versionStatus === "up_to_date"
? t("dashboard.upToDate")
@@ -483,11 +519,7 @@ export function Dashboard({
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Clock
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<Clock size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.uptime")}
</p>
@@ -502,11 +534,7 @@ export function Dashboard({
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Database
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<Database size={20} className="shrink-0" />
<p className="ml-2 leading-none truncate">
{t("dashboard.database")}
</p>
@@ -524,13 +552,9 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Server
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<Server size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalServers")}
</p>
@@ -539,13 +563,9 @@ export function Dashboard({
{totalServers}
</p>
</div>
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Network
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<ArrowDownUp size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalTunnels")}
</p>
@@ -556,13 +576,9 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center justify-between bg-canvas w-full h-auto mt-3 border-2 border-edge rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Key
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<Key size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalCredentials")}
</p>
@@ -574,7 +590,7 @@ export function Dashboard({
</div>
</div>
</div>
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<div className="flex flex-row items-center justify-between mb-3 mt-1">
<p className="text-xl font-semibold flex flex-row items-center">
@@ -584,14 +600,14 @@ export function Dashboard({
<Button
variant="outline"
size="sm"
className="border-2 !border-dark-border h-7"
className="border-2 !border-edge h-7 !bg-canvas"
onClick={handleResetActivity}
>
{t("dashboard.reset")}
</Button>
</div>
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
@@ -603,101 +619,171 @@ export function Dashboard({
{t("dashboard.noRecentActivity")}
</p>
) : (
recentActivity.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : (
<FolderOpen size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">
{item.hostName}
</p>
</Button>
))
recentActivity
.filter((item, index, array) => {
if (index === 0) return true;
const prevItem = array[index - 1];
return !(
item.hostId === prevItem.hostId &&
item.type === prevItem.type
);
})
.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-edge !bg-canvas min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : item.type === "file_manager" ? (
<FolderOpen size={20} className="shrink-0" />
) : item.type === "server_stats" ? (
<Server size={20} className="shrink-0" />
) : item.type === "tunnel" ? (
<ArrowDownUp size={20} className="shrink-0" />
) : item.type === "docker" ? (
<Container size={20} className="shrink-0" />
) : (
<Terminal size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">
{item.hostName}
</p>
</Button>
))
)}
</div>
</div>
</div>
</div>
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden thin-scrollbar">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<FastForward className="mr-3" />
{t("dashboard.quickActions")}
</p>
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden">
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden thin-scrollbar">
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={handleAddHost}
>
<Server
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span className="font-semibold text-sm mt-2">
{t("dashboard.addHost")}
</span>
<div className="flex flex-col items-center w-full max-w-full">
<Server
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.addHost")}
</span>
</div>
</Button>
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={handleAddCredential}
>
<Key
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span className="font-semibold text-sm mt-2">
{t("dashboard.addCredential")}
</span>
<div className="flex flex-col items-center w-full max-w-full">
<Key
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.addCredential")}
</span>
</div>
</Button>
{isAdmin && (
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={handleOpenAdminSettings}
>
<Settings
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span className="font-semibold text-sm mt-2">
{t("dashboard.adminSettings")}
</span>
<div className="flex flex-col items-center w-full max-w-full">
<Settings
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.adminSettings")}
</span>
</div>
</Button>
)}
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
className="border-2 !border-edge flex flex-col items-center justify-center h-auto p-3 !bg-canvas"
onClick={handleOpenUserProfile}
>
<User
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span className="font-semibold text-sm mt-2">
{t("dashboard.userProfile")}
</span>
<div className="flex flex-col items-center w-full max-w-full">
<User
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span
className="font-semibold text-sm mt-2 text-center block"
style={{
wordWrap: "break-word",
overflowWrap: "break-word",
width: "100%",
maxWidth: "100%",
hyphens: "auto",
display: "block",
whiteSpace: "normal",
}}
>
{t("dashboard.userProfile")}
</span>
</div>
</Button>
</div>
</div>
</div>
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex-1 min-w-0 border-2 border-edge rounded-md bg-elevated flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<ChartLine className="mr-3" />
{t("dashboard.serverStats")}
</p>
<div
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden thin-scrollbar ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{serverStatsLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
@@ -713,7 +799,10 @@ export function Dashboard({
<Button
key={server.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg h-auto p-3 min-w-0"
className="border-2 !border-edge bg-canvas h-auto p-3 min-w-0 !bg-canvas"
onClick={() =>
handleServerStatClick(server.id, server.name)
}
>
<div className="flex flex-col w-full">
<div className="flex flex-row items-center mb-2">

View File

@@ -97,7 +97,7 @@ export function UpdateLog({ loggedIn }: UpdateLogProps) {
<Button
variant="outline"
size="sm"
className="ml-2 text-sm border-1 border-dark-border text-muted-foreground"
className="ml-2 text-sm border-1 border-edge text-muted-foreground"
onClick={() => setIsOpen(true)}
>
{t("common.updatesAndReleases")}
@@ -106,10 +106,10 @@ export function UpdateLog({ loggedIn }: UpdateLogProps) {
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetContent
side="right"
className="w-[500px] bg-dark-bg border-l-2 border-dark-border text-white sm:max-w-[500px] p-0 flex flex-col [&>button]:hidden"
className="w-[500px] bg-canvas border-l-2 border-edge text-foreground sm:max-w-[500px] p-0 flex flex-col [&>button]:hidden"
>
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
<div className="flex items-center justify-between p-4 border-b border-edge">
<h2 className="text-lg font-semibold text-foreground">
{t("common.updatesAndReleases")}
</h2>
<Button
@@ -123,13 +123,13 @@ export function UpdateLog({ loggedIn }: UpdateLogProps) {
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="flex-1 overflow-y-auto p-4 thin-scrollbar">
{versionInfo && versionInfo.status === "requires_update" && (
<Alert className="bg-dark-bg-darker border-dark-border text-white mb-3">
<AlertTitle className="text-white">
<Alert className="bg-elevated border-edge text-foreground mb-3">
<AlertTitle className="text-foreground">
{t("common.updateAvailable")}
</AlertTitle>
<AlertDescription className="text-gray-300">
<AlertDescription className="text-foreground-secondary">
{t("common.newVersionAvailable", {
version: versionInfo.version,
})}
@@ -161,11 +161,11 @@ export function UpdateLog({ loggedIn }: UpdateLogProps) {
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50"
className="border border-edge rounded-lg p-3 hover:bg-elevated transition-colors cursor-pointer bg-elevated/50"
onClick={() => window.open(release.link, "_blank")}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
<h4 className="font-semibold text-sm leading-tight flex-1 text-foreground">
{release.title}
</h4>
{release.isPrerelease && (
@@ -175,11 +175,11 @@ export function UpdateLog({ loggedIn }: UpdateLogProps) {
)}
</div>
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
<p className="text-xs text-foreground-secondary mb-2 leading-relaxed">
{formatDescription(release.description)}
</p>
<div className="flex items-center text-xs text-gray-400">
<div className="flex items-center text-xs text-muted-foreground">
<span>
{new Date(release.pubDate).toLocaleDateString()}
</span>
@@ -198,11 +198,11 @@ export function UpdateLog({ loggedIn }: UpdateLogProps) {
</div>
{releases && releases.items.length === 0 && !loading && (
<Alert className="bg-dark-bg-darker border-dark-border text-gray-300">
<AlertTitle className="text-gray-300">
<Alert className="bg-elevated border-edge text-foreground-secondary">
<AlertTitle className="text-foreground-secondary">
{t("common.noReleases")}
</AlertTitle>
<AlertDescription className="text-gray-400">
<AlertDescription className="text-muted-foreground">
{t("common.noReleasesFound")}
</AlertDescription>
</Alert>

View File

@@ -0,0 +1,606 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
import {
connectDockerSession,
disconnectDockerSession,
listDockerContainers,
validateDockerAvailability,
keepaliveDockerSession,
verifyDockerTOTP,
logActivity,
} from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import { ContainerList } from "./components/ContainerList.tsx";
import { ContainerDetail } from "./components/ContainerDetail.tsx";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
interface DockerManagerProps {
hostConfig?: SSHHost;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
onClose?: () => void;
}
interface TabData {
id: number;
type: string;
[key: string]: unknown;
}
export function DockerManager({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
onClose,
}: DockerManagerProps): React.ReactElement {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const { currentTab, removeTab } = useTabs() as {
currentTab: number | null;
removeTab: (tabId: number) => void;
};
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [sessionId, setSessionId] = React.useState<string | null>(null);
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
const [selectedContainer, setSelectedContainer] = React.useState<
string | null
>(null);
const [isConnecting, setIsConnecting] = React.useState(false);
const [activeTab, setActiveTab] = React.useState("containers");
const [dockerValidation, setDockerValidation] =
React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false);
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
const [authReason, setAuthReason] = React.useState<
"no_keyboard" | "auth_failed" | "timeout"
>("no_keyboard");
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const logDockerActivity = async () => {
if (
!currentHostConfig?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName =
currentHostConfig.name ||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
await logActivity("docker", currentHostConfig.id, hostName);
} catch (err) {
console.warn("Failed to log docker activity:", err);
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
};
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setCurrentHostConfig(hostConfig);
setContainers([]);
setSelectedContainer(null);
setSessionId(null);
setDockerValidation(null);
setViewMode("list");
}
}, [hostConfig?.id]);
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
const initializingRef = React.useRef(false);
React.useEffect(() => {
const initSession = async () => {
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
return;
}
if (initializingRef.current) return;
initializingRef.current = true;
if (sessionId) {
initializingRef.current = false;
return;
}
setIsConnecting(true);
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason(
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
);
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
toast.error(
validation.error || "Docker is not available on this host",
);
} else {
logDockerActivity();
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to connect to host",
);
setIsConnecting(false);
setIsValidating(false);
onClose?.();
} finally {
setIsConnecting(false);
}
};
initSession();
return () => {
initializingRef.current = false;
if (sessionId) {
disconnectDockerSession(sessionId).catch(() => {
// Silently handle disconnect errors
});
}
};
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
React.useEffect(() => {
if (!sessionId || !isVisible) return;
const keepalive = setInterval(
() => {
keepaliveDockerSession(sessionId).catch(() => {
// Silently handle keepalive errors
});
},
10 * 60 * 1000,
);
return () => clearInterval(keepalive);
}, [sessionId, isVisible]);
const refreshContainers = React.useCallback(async () => {
if (!sessionId) return;
try {
const data = await listDockerContainers(sessionId, true);
setContainers(data);
} catch (error) {
// Silently handle polling errors
}
}, [sessionId]);
React.useEffect(() => {
if (!sessionId || !isVisible || !dockerValidation?.available) return;
let cancelled = false;
const pollContainers = async () => {
try {
setIsLoadingContainers(true);
const data = await listDockerContainers(sessionId, true);
if (!cancelled) {
setContainers(data);
}
} catch (error) {
// Silently handle polling errors
} finally {
if (!cancelled) {
setIsLoadingContainers(false);
}
}
};
pollContainers();
const interval = setInterval(pollContainers, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [sessionId, isVisible, dockerValidation?.available]);
const handleBack = React.useCallback(() => {
setViewMode("list");
setSelectedContainer(null);
}, []);
const handleTotpSubmit = async (code: string) => {
if (!totpSessionId || !code) return;
try {
setIsConnecting(true);
const result = await verifyDockerTOTP(totpSessionId, code);
if (result?.status === "success") {
setTotpRequired(false);
setTotpPrompt("");
setSessionId(totpSessionId);
setTotpSessionId(null);
setIsValidating(true);
const validation = await validateDockerAvailability(totpSessionId);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
toast.error(
validation.error || "Docker is not available on this host",
);
} else {
logDockerActivity();
}
}
} catch (error) {
console.error("TOTP verification failed:", error);
toast.error(t("docker.totpVerificationFailed"));
} finally {
setIsConnecting(false);
}
};
const handleTotpCancel = () => {
setTotpRequired(false);
setTotpSessionId(null);
setTotpPrompt("");
setIsConnecting(false);
if (currentTab !== null) {
removeTab(currentTab);
}
};
const handleAuthSubmit = async (credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) => {
if (!currentHostConfig?.id) return;
setShowAuthDialog(false);
setIsConnecting(true);
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
userProvidedPassword: credentials.password,
userProvidedSshKey: credentials.sshKey,
userProvidedKeyPassword: credentials.keyPassword,
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason("auth_failed");
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
toast.error(validation.error || "Docker is not available on this host");
} else {
logDockerActivity();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to connect");
setIsConnecting(false);
setIsValidating(false);
onClose?.();
} finally {
setIsConnecting(false);
}
};
const handleAuthCancel = () => {
setShowAuthDialog(false);
setIsConnecting(false);
onClose?.();
};
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
if (!currentHostConfig?.enableDocker) {
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{t("docker.notEnabled")}</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
if (isConnecting || isValidating) {
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 relative">
<SimpleLoader
visible={true}
message={
isValidating ? t("docker.validating") : t("docker.connecting")
}
/>
</div>
</div>
</div>
);
}
if (dockerValidation && !dockerValidation.available) {
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-2">{t("docker.error")}</div>
<div>{dockerValidation.error}</div>
{dockerValidation.code && (
<div className="mt-2 text-xs opacity-70">
{t("docker.errorCode", { code: dockerValidation.code })}
</div>
)}
</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
{dockerValidation?.version && (
<p className="text-xs text-muted-foreground">
{t("docker.version", { version: dockerValidation.version })}
</p>
)}
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 relative">
{viewMode === "list" ? (
<div className="h-full px-4 py-4">
{sessionId ? (
isLoadingContainers && containers.length === 0 ? (
<SimpleLoader
visible={true}
message={t("docker.loadingContainers")}
/>
) : (
<ContainerList
containers={containers}
sessionId={sessionId}
onSelectContainer={(id) => {
setSelectedContainer(id);
setViewMode("detail");
}}
selectedContainerId={selectedContainer}
onRefresh={refreshContainers}
/>
)
) : (
<div className="text-center py-8">
<p className="text-muted-foreground">No session available</p>
</div>
)}
</div>
) : sessionId && selectedContainer && currentHostConfig ? (
<ContainerDetail
sessionId={sessionId}
containerId={selectedContainer}
containers={containers}
hostConfig={currentHostConfig}
onBack={handleBack}
/>
) : (
<div className="text-center py-8">
<p className="text-muted-foreground">
Select a container to view details
</p>
</div>
)}
</div>
</div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
{currentHostConfig && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authReason}
onSubmit={handleAuthSubmit}
onCancel={handleAuthCancel}
hostInfo={{
ip: currentHostConfig.ip,
port: currentHostConfig.port,
username: currentHostConfig.username,
name: currentHostConfig.name,
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,441 @@
import React from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/ui/button.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Card, CardContent } from "@/components/ui/card.tsx";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { useTranslation } from "react-i18next";
interface ConsoleTerminalProps {
sessionId: string;
containerId: string;
containerName: string;
containerState: string;
hostConfig: SSHHost;
}
export function ConsoleTerminal({
sessionId,
containerId,
containerName,
containerState,
hostConfig,
}: ConsoleTerminalProps): React.ReactElement {
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const [isConnected, setIsConnected] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false);
const [selectedShell, setSelectedShell] = React.useState<string>("bash");
const wsRef = React.useRef<WebSocket | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null);
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (!terminal) return;
const fitAddon = new FitAddon();
const clipboardAddon = new ClipboardAddon();
const webLinksAddon = new WebLinksAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(webLinksAddon);
terminal.options.cursorBlink = true;
terminal.options.fontSize = 14;
terminal.options.fontFamily = "monospace";
const backgroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--bg-elevated")
.trim();
const foregroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--foreground")
.trim();
terminal.options.theme = {
background: backgroundColor || "var(--bg-elevated)",
foreground: foregroundColor || "var(--foreground)",
};
setTimeout(() => {
fitAddon.fit();
}, 100);
const resizeHandler = () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const { rows, cols } = terminal;
wsRef.current.send(
JSON.stringify({
type: "resize",
data: { rows, cols },
}),
);
}
}
};
window.addEventListener("resize", resizeHandler);
return () => {
window.removeEventListener("resize", resizeHandler);
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) {}
wsRef.current.close();
wsRef.current = null;
}
terminal.dispose();
};
}, [terminal]);
const disconnect = React.useCallback(() => {
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) {}
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
if (terminal) {
try {
terminal.clear();
} catch (error) {}
}
}, [terminal, t]);
const connect = React.useCallback(() => {
if (!terminal || containerState !== "running") {
toast.error(t("docker.containerMustBeRunning"));
return;
}
setIsConnecting(true);
try {
const token = isElectron()
? localStorage.getItem("jwt")
: getCookie("jwt");
if (!token) {
toast.error(t("docker.authenticationRequired"));
setIsConnecting(false);
return;
}
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30008`
: isElectronApp
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/docker/console/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/docker/console/`;
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
const cols = terminal.cols || 80;
const rows = terminal.rows || 24;
ws.send(
JSON.stringify({
type: "connect",
data: {
hostConfig,
containerId,
shell: selectedShell,
cols,
rows,
},
}),
);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "output":
terminal.write(msg.data);
break;
case "connected":
setIsConnected(true);
setIsConnecting(false);
if (msg.data?.shellChanged) {
toast.warning(
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
);
} else {
toast.success(t("docker.connectedTo", { containerName }));
}
setTimeout(() => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
data: { rows: terminal.rows, cols: terminal.cols },
}),
);
}
}, 100);
break;
case "disconnected":
setIsConnected(false);
setIsConnecting(false);
terminal.write(
`\r\n\x1b[1;33m${msg.message || t("docker.disconnected")}\x1b[0m\r\n`,
);
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
break;
case "error":
setIsConnecting(false);
toast.error(msg.message || t("docker.consoleError"));
terminal.write(
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`,
);
break;
}
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
setIsConnecting(false);
setIsConnected(false);
toast.error(t("docker.failedToConnect"));
};
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
ws.onclose = () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
setIsConnected(false);
setIsConnecting(false);
if (wsRef.current === ws) {
wsRef.current = null;
}
};
wsRef.current = ws;
terminal.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "input",
data,
}),
);
}
});
} catch (error) {
setIsConnecting(false);
toast.error(
`Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}, [
terminal,
containerState,
hostConfig,
containerId,
selectedShell,
containerName,
t,
]);
React.useEffect(() => {
return () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch (error) {}
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
};
}, []);
if (containerState !== "running") {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground text-lg">
{t("docker.containerNotRunning")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.startContainerToAccess")}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
<Card className="py-3">
<CardContent className="px-3">
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
<div className="flex items-center gap-2 flex-1">
<TerminalIcon className="h-5 w-5" />
<span className="text-base font-medium">
{t("docker.console")}
</span>
</div>
<Select
value={selectedShell}
onValueChange={setSelectedShell}
disabled={isConnected}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("docker.selectShell")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">{t("docker.bash")}</SelectItem>
<SelectItem value="sh">{t("docker.sh")}</SelectItem>
<SelectItem value="ash">{t("docker.ash")}</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2 sm:gap-2">
{!isConnected ? (
<Button
onClick={connect}
disabled={isConnecting}
className="min-w-[120px]"
>
{isConnecting ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
{t("docker.connecting")}
</>
) : (
<>
<Power className="h-4 w-4 mr-2" />
{t("docker.connect")}
</>
)}
</Button>
) : (
<Button
onClick={disconnect}
variant="destructive"
className="min-w-[120px]"
>
<PowerOff className="h-4 w-4 mr-2" />
{t("docker.disconnect")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
<Card className="flex-1 overflow-hidden pt-1 pb-0">
<CardContent className="p-0 h-full relative">
<div
ref={xtermRef}
className="h-full w-full"
style={{ display: isConnected ? "block" : "none" }}
/>
{!isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground">
{t("docker.notConnected")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.clickToConnect")}
</p>
</div>
</div>
)}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-muted-foreground mt-4">
{t("docker.connectingTo", { containerName })}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,432 @@
import React from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
Play,
Square,
RotateCw,
Pause,
Trash2,
PlayCircle,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types";
import {
startDockerContainer,
stopDockerContainer,
restartDockerContainer,
pauseDockerContainer,
unpauseDockerContainer,
removeDockerContainer,
} from "@/ui/main-axios.ts";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
interface ContainerCardProps {
container: DockerContainer;
sessionId: string;
onSelect?: () => void;
isSelected?: boolean;
onRefresh?: () => void;
}
export function ContainerCard({
container,
sessionId,
onSelect,
isSelected = false,
onRefresh,
}: ContainerCardProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [isStarting, setIsStarting] = React.useState(false);
const [isStopping, setIsStopping] = React.useState(false);
const [isRestarting, setIsRestarting] = React.useState(false);
const [isPausing, setIsPausing] = React.useState(false);
const [isRemoving, setIsRemoving] = React.useState(false);
const statusColors = {
running: {
bg: "bg-green-500/10",
border: "border-green-500/20",
text: "text-green-400",
badge: "bg-green-500/20 text-green-300 border-green-500/30",
},
exited: {
bg: "bg-red-500/10",
border: "border-red-500/20",
text: "text-red-400",
badge: "bg-red-500/20 text-red-300 border-red-500/30",
},
paused: {
bg: "bg-yellow-500/10",
border: "border-yellow-500/20",
text: "text-yellow-400",
badge: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
},
created: {
bg: "bg-blue-500/10",
border: "border-blue-500/20",
text: "text-blue-400",
badge: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
restarting: {
bg: "bg-orange-500/10",
border: "border-orange-500/20",
text: "text-orange-400",
badge: "bg-orange-500/20 text-orange-300 border-orange-500/30",
},
removing: {
bg: "bg-purple-500/10",
border: "border-purple-500/20",
text: "text-purple-400",
badge: "bg-purple-500/20 text-purple-300 border-purple-500/30",
},
dead: {
bg: "bg-muted/10",
border: "border-muted/20",
text: "text-muted-foreground",
badge: "bg-muted/20 text-muted-foreground border-muted/30",
},
};
const colors = statusColors[container.state] || statusColors.created;
const handleStart = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsStarting(true);
try {
await startDockerContainer(sessionId, container.id);
toast.success(t("docker.containerStarted", { name: container.name }));
onRefresh?.();
} catch (error) {
toast.error(
t("docker.failedToStartContainer", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
} finally {
setIsStarting(false);
}
};
const handleStop = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsStopping(true);
try {
await stopDockerContainer(sessionId, container.id);
toast.success(t("docker.containerStopped", { name: container.name }));
onRefresh?.();
} catch (error) {
toast.error(
t("docker.failedToStopContainer", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
} finally {
setIsStopping(false);
}
};
const handleRestart = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsRestarting(true);
try {
await restartDockerContainer(sessionId, container.id);
toast.success(t("docker.containerRestarted", { name: container.name }));
onRefresh?.();
} catch (error) {
toast.error(
t("docker.failedToRestartContainer", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
} finally {
setIsRestarting(false);
}
};
const handlePause = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsPausing(true);
try {
if (container.state === "paused") {
await unpauseDockerContainer(sessionId, container.id);
toast.success(t("docker.containerUnpaused", { name: container.name }));
} else {
await pauseDockerContainer(sessionId, container.id);
toast.success(t("docker.containerPaused", { name: container.name }));
}
onRefresh?.();
} catch (error) {
toast.error(
t("docker.failedToTogglePauseContainer", {
action: container.state === "paused" ? "unpause" : "pause",
error: error instanceof Error ? error.message : "Unknown error",
}),
);
} finally {
setIsPausing(false);
}
};
const handleRemove = async (e: React.MouseEvent) => {
e.stopPropagation();
const containerName = container.name.startsWith("/")
? container.name.slice(1)
: container.name;
let confirmMessage = t("docker.confirmRemoveContainer", {
name: containerName,
});
if (container.state === "running") {
confirmMessage += " " + t("docker.runningContainerWarning");
}
confirmWithToast(
confirmMessage,
async () => {
setIsRemoving(true);
try {
const force = container.state === "running";
await removeDockerContainer(sessionId, container.id, force);
toast.success(t("docker.containerRemoved", { name: containerName }));
onRefresh?.();
} catch (error) {
toast.error(
t("docker.failedToRemoveContainer", {
error: error instanceof Error ? error.message : "Unknown error",
}),
);
} finally {
setIsRemoving(false);
}
},
t("common.remove"),
t("common.cancel"),
);
};
const isLoading =
isStarting || isStopping || isRestarting || isPausing || isRemoving;
const formatCreatedDate = (dateStr: string): string => {
try {
const cleanDate = dateStr.replace(/\s*\+\d{4}\s*UTC\s*$/, "").trim();
return cleanDate;
} catch {
return dateStr;
}
};
const parsePorts = (portsStr: string | undefined): string[] => {
if (!portsStr || portsStr.trim() === "") return [];
return portsStr
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
};
const portsList = parsePorts(container.ports);
return (
<>
<Card
className={`cursor-pointer transition-all hover:shadow-lg ${
isSelected
? "ring-2 ring-primary border-primary"
: `border-2 ${colors.border}`
} ${colors.bg} pt-3 pb-0`}
onClick={onSelect}
>
<CardHeader className="pb-2 px-4">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base font-semibold truncate flex-1">
{container.name.startsWith("/")
? container.name.slice(1)
: container.name}
</CardTitle>
<Badge className={`${colors.badge} border shrink-0`}>
{container.state}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-3">
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-[50px] text-xs">
{t("docker.image")}
</span>
<span className="truncate text-foreground text-xs">
{container.image}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-[50px] text-xs">
{t("docker.idLabel")}
</span>
<span className="font-mono text-xs text-foreground">
{container.id.substring(0, 12)}
</span>
</div>
<div className="flex items-start gap-2">
<span className="text-muted-foreground min-w-[50px] text-xs shrink-0">
{t("docker.ports")}
</span>
<div className="flex flex-wrap gap-1">
{portsList.length > 0 ? (
portsList.map((port, idx) => (
<Badge
key={idx}
variant="outline"
className="text-xs font-mono bg-muted/10 text-muted-foreground border-muted/30"
>
{port}
</Badge>
))
) : (
<Badge
variant="outline"
className="text-xs bg-muted/10 text-muted-foreground border-muted/30"
>
{t("docker.noPorts")}
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground min-w-[50px] text-xs">
{t("docker.created")}
</span>
<span className="text-foreground text-xs">
{formatCreatedDate(container.created)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2 border-t border-edge-panel">
<TooltipProvider>
{container.state !== "running" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleStart}
disabled={isLoading}
>
{isStarting ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-edge-hover border-t-transparent" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t("docker.start")}</TooltipContent>
</Tooltip>
)}
{container.state === "running" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleStop}
disabled={isLoading}
>
{isStopping ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-edge-hover border-t-transparent" />
) : (
<Square className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t("docker.stop")}</TooltipContent>
</Tooltip>
)}
{(container.state === "running" ||
container.state === "paused") && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handlePause}
disabled={isLoading}
>
{isPausing ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-edge-hover border-t-transparent" />
) : container.state === "paused" ? (
<PlayCircle className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{container.state === "paused"
? t("docker.unpause")
: t("docker.pause")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleRestart}
disabled={isLoading || container.state === "exited"}
>
{isRestarting ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-edge-hover border-t-transparent" />
) : (
<RotateCw className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t("docker.restart")}</TooltipContent>{" "}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 text-red-400 hover:text-red-300 hover:bg-red-500/20"
onClick={handleRemove}
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("docker.remove")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardContent>
</Card>
</>
);
}

View File

@@ -0,0 +1,124 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { ArrowLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { DockerContainer, SSHHost } from "@/types";
import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx";
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
interface ContainerDetailProps {
sessionId: string;
containerId: string;
containers: DockerContainer[];
hostConfig: SSHHost;
onBack: () => void;
}
export function ContainerDetail({
sessionId,
containerId,
containers,
hostConfig,
onBack,
}: ContainerDetailProps): React.ReactElement {
const { t } = useTranslation();
const [activeTab, setActiveTab] = React.useState("logs");
const container = containers.find((c) => c.id === containerId);
if (!container) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-muted-foreground text-lg">
{t("docker.containerNotFound")}
</p>
<Button onClick={onBack} variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
{t("docker.backToList")}
</Button>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-4 px-4 pt-3 pb-3">
<Button variant="ghost" onClick={onBack} size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
{t("common.back")}
</Button>
<div className="min-w-0 flex-1">
<h2 className="font-bold text-lg truncate">{container.name}</h2>
<p className="text-sm text-muted-foreground truncate">
{container.image}
</p>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="h-full flex flex-col"
>
<div className="px-4 pt-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="logs">{t("docker.logs")}</TabsTrigger>
<TabsTrigger value="stats">{t("docker.stats")}</TabsTrigger>
<TabsTrigger value="console">
{t("docker.consoleTab")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="logs"
className="flex-1 overflow-auto thin-scrollbar px-3 pb-3 mt-3"
>
<LogViewer
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
/>
</TabsContent>
<TabsContent
value="stats"
className="flex-1 overflow-auto thin-scrollbar px-3 pb-3 mt-3"
>
<ContainerStats
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
containerState={container.state}
/>
</TabsContent>
<TabsContent
value="console"
className="flex-1 overflow-hidden px-3 pb-3 mt-3"
>
<ConsoleTerminal
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
containerState={container.state}
hostConfig={hostConfig}
/>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import React from "react";
import { Input } from "@/components/ui/input.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Search, Filter } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types";
import { ContainerCard } from "./ContainerCard.tsx";
interface ContainerListProps {
containers: DockerContainer[];
sessionId: string;
onSelectContainer: (containerId: string) => void;
selectedContainerId?: string | null;
onRefresh?: () => void;
}
export function ContainerList({
containers,
sessionId,
onSelectContainer,
selectedContainerId = null,
onRefresh,
}: ContainerListProps): React.ReactElement {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<string>("all");
const filteredContainers = React.useMemo(() => {
return containers.filter((container) => {
const matchesSearch =
container.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
container.image.toLowerCase().includes(searchQuery.toLowerCase()) ||
container.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus =
statusFilter === "all" || container.state === statusFilter;
return matchesSearch && matchesStatus;
});
}, [containers, searchQuery, statusFilter]);
const statusCounts = React.useMemo(() => {
const counts: Record<string, number> = {};
containers.forEach((c) => {
counts[c.state] = (counts[c.state] || 0) + 1;
});
return counts;
}, [containers]);
if (containers.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-muted-foreground text-lg">
{t("docker.noContainersFound")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.noContainersFoundHint")}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("docker.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2 sm:min-w-[200px]">
<Filter className="h-4 w-4 text-muted-foreground" />
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("docker.filterByStatusPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("docker.allContainersCount", { count: containers.length })}
</SelectItem>
{Object.entries(statusCounts).map(([status, count]) => (
<SelectItem key={status} value={status}>
{t("docker.statusCount", {
status: status.charAt(0).toUpperCase() + status.slice(1),
count,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{filteredContainers.length === 0 ? (
<div className="flex items-center justify-center flex-1">
<div className="text-center space-y-2">
<p className="text-muted-foreground">
{t("docker.noContainersMatchFilters")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.noContainersMatchFiltersHint")}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3 overflow-auto thin-scrollbar pb-2">
{filteredContainers.map((container) => (
<ContainerCard
key={container.id}
container={container}
sessionId={sessionId}
onSelect={() => onSelectContainer(container.id)}
isSelected={selectedContainerId === container.id}
onRefresh={onRefresh}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,256 @@
import React from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { Progress } from "@/components/ui/progress.tsx";
import { Cpu, MemoryStick, Network, HardDrive, Activity } from "lucide-react";
import type { DockerStats } from "@/types";
import { getContainerStats } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { useTranslation } from "react-i18next";
interface ContainerStatsProps {
sessionId: string;
containerId: string;
containerName: string;
containerState: string;
}
export function ContainerStats({
sessionId,
containerId,
containerName,
containerState,
}: ContainerStatsProps): React.ReactElement {
const { t } = useTranslation();
const [stats, setStats] = React.useState<DockerStats | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const fetchStats = React.useCallback(async () => {
if (containerState !== "running") {
setError(t("docker.containerMustBeRunningToViewStats"));
return;
}
setIsLoading(true);
setError(null);
try {
const data = await getContainerStats(sessionId, containerId);
setStats(data);
} catch (err) {
setError(
err instanceof Error ? err.message : t("docker.failedToFetchStats"),
);
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, containerState]);
React.useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 2000);
return () => clearInterval(interval);
}, [fetchStats]);
if (containerState !== "running") {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<Activity className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground text-lg">
{t("docker.containerNotRunning")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.startContainerToViewStats")}
</p>
</div>
</div>
);
}
if (isLoading && !stats) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-muted-foreground mt-4">
{t("docker.loadingStats")}
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-red-400 text-lg">
{t("docker.errorLoadingStats")}
</p>
<p className="text-muted-foreground text-sm">{error}</p>
</div>
</div>
);
}
if (!stats) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">{t("docker.noStatsAvailable")}</p>
</div>
);
}
const cpuPercent = parseFloat(stats.cpu) || 0;
const memPercent = parseFloat(stats.memoryPercent) || 0;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 h-full overflow-auto thin-scrollbar">
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Cpu className="h-5 w-5 text-blue-400" />
{t("docker.cpuUsage")}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">
{t("docker.current")}
</span>{" "}
<span className="font-mono font-semibold text-blue-400">
{stats.cpu}
</span>
</div>
<Progress value={Math.min(cpuPercent, 100)} className="h-2" />
</div>
</CardContent>
</Card>
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<MemoryStick className="h-5 w-5 text-purple-400" />
{t("docker.memoryUsage")}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">
{t("docker.usedLimit")}
</span>
<span className="font-mono font-semibold text-purple-400">
{stats.memoryUsed} / {stats.memoryLimit}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">
{t("docker.percentage")}
</span>
<span className="font-mono text-purple-400">
{stats.memoryPercent}
</span>
</div>
<Progress value={Math.min(memPercent, 100)} className="h-2" />
</div>
</CardContent>
</Card>
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Network className="h-5 w-5 text-green-400" />
{t("docker.networkIo")}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">{t("docker.input")}</span>
<span className="font-mono text-green-400">{stats.netInput}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">
{t("docker.output")}
</span>
<span className="font-mono text-green-400">
{stats.netOutput}
</span>
</div>
</div>
</CardContent>
</Card>
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<HardDrive className="h-5 w-5 text-orange-400" />
{t("docker.blockIo")}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">{t("docker.read")}</span>
<span className="font-mono text-orange-400">
{stats.blockRead}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">{t("docker.write")}</span>
<span className="font-mono text-orange-400">
{stats.blockWrite}
</span>
</div>
{stats.pids && (
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">
{t("docker.pids")}
</span>
<span className="font-mono text-orange-400">{stats.pids}</span>
</div>
)}
</div>
</CardContent>
</Card>
<Card className="md:col-span-2 py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Activity className="h-5 w-5 text-cyan-400" />
{t("docker.containerInformation")}
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t("docker.name")}</span>
<span className="font-mono text-foreground">{containerName}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t("docker.id")}</span>
<span className="font-mono text-sm text-foreground">
{containerId.substring(0, 12)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{t("docker.state")}</span>
<span className="font-semibold text-green-400 capitalize">
{containerState}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,239 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Card, CardContent } from "@/components/ui/card.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Download, RefreshCw, Filter } from "lucide-react";
import { toast } from "sonner";
import type { DockerLogOptions } from "@/types";
import { getContainerLogs, downloadContainerLogs } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface LogViewerProps {
sessionId: string;
containerId: string;
containerName: string;
}
export function LogViewer({
sessionId,
containerId,
containerName,
}: LogViewerProps): React.ReactElement {
const [logs, setLogs] = React.useState<string>("");
const [isLoading, setIsLoading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
const [tailLines, setTailLines] = React.useState<string>("100");
const [showTimestamps, setShowTimestamps] = React.useState(false);
const [autoRefresh, setAutoRefresh] = React.useState(false);
const [searchFilter, setSearchFilter] = React.useState("");
const logsEndRef = React.useRef<HTMLDivElement>(null);
const fetchLogs = React.useCallback(async () => {
setIsLoading(true);
try {
const options: DockerLogOptions = {
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
timestamps: showTimestamps,
};
const data = await getContainerLogs(sessionId, containerId, options);
setLogs(data.logs);
} catch (error) {
toast.error(
`Failed to fetch logs: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, tailLines, showTimestamps]);
React.useEffect(() => {
fetchLogs();
}, [fetchLogs]);
React.useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(() => {
fetchLogs();
}, 3000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
React.useEffect(() => {
if (autoRefresh && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [logs, autoRefresh]);
const handleDownload = async () => {
setIsDownloading(true);
try {
const options: DockerLogOptions = {
timestamps: showTimestamps,
};
const blob = await downloadContainerLogs(sessionId, containerId, options);
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${containerName.replace(/[^a-z0-9]/gi, "_")}_logs.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success("Logs downloaded successfully");
} catch (error) {
toast.error(
`Failed to download logs: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsDownloading(false);
}
};
const filteredLogs = React.useMemo(() => {
if (!searchFilter.trim()) return logs;
return logs
.split("\n")
.filter((line) => line.toLowerCase().includes(searchFilter.toLowerCase()))
.join("\n");
}, [logs, searchFilter]);
return (
<div className="flex flex-col h-full gap-3">
<Card className="py-3">
<CardContent className="px-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="flex flex-col">
<Label htmlFor="tail-lines" className="mb-1">
Lines to show
</Label>
<Select value={tailLines} onValueChange={setTailLines}>
<SelectTrigger id="tail-lines">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">Last 50 lines</SelectItem>
<SelectItem value="100">Last 100 lines</SelectItem>
<SelectItem value="500">Last 500 lines</SelectItem>
<SelectItem value="1000">Last 1000 lines</SelectItem>
<SelectItem value="all">All logs</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col">
<Label htmlFor="timestamps" className="mb-1">
Show Timestamps
</Label>
<div className="flex items-center h-10 px-3 border rounded-md">
<Switch
id="timestamps"
checked={showTimestamps}
onCheckedChange={setShowTimestamps}
/>
<span className="ml-2 text-sm">
{showTimestamps ? "Enabled" : "Disabled"}
</span>
</div>
</div>
<div className="flex flex-col">
<Label htmlFor="auto-refresh" className="mb-1">
Auto Refresh
</Label>
<div className="flex items-center h-10 px-3 border rounded-md">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<span className="ml-2 text-sm">
{autoRefresh ? "On" : "Off"}
</span>
</div>
</div>
<div className="flex flex-col">
<Label className="mb-1">Actions</Label>
<div className="flex gap-2 h-10">
<Button
size="sm"
variant="outline"
onClick={fetchLogs}
disabled={isLoading}
className="flex-1 h-full"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-edge-hover border-t-transparent" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDownload}
disabled={isDownloading}
className="flex-1 h-full"
>
{isDownloading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-edge-hover border-t-transparent" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<div className="mt-2">
<div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Filter logs..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-input rounded-md text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
</CardContent>
</Card>
<Card className="flex-1 overflow-hidden py-0">
<CardContent className="p-0 h-full">
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full">
<SimpleLoader size="lg" />
</div>
) : (
<div className="h-full overflow-auto thin-scrollbar">
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-foreground leading-relaxed">
{filteredLogs || (
<span className="text-muted-foreground">
No logs available
</span>
)}
<div ref={logsEndRef} />
</pre>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -79,7 +79,7 @@ export function DragIndicator({
<div
className={cn(
"fixed top-4 right-4 z-50 min-w-[300px] max-w-[400px]",
"bg-dark-bg border border-dark-border rounded-lg shadow-lg",
"bg-canvas border border-edge rounded-lg shadow-lg",
"p-4 transition-all duration-300 ease-in-out",
isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-full",
className,
@@ -109,7 +109,7 @@ export function DragIndicator({
</div>
{(isDownloading || isDragging) && !error && (
<div className="w-full bg-dark-border rounded-full h-2 mb-2">
<div className="w-full bg-border-base rounded-full h-2 mb-2">
<div
className={cn(
"h-2 rounded-full transition-all duration-300",

View File

@@ -1,23 +1,26 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { FileManagerGrid } from "./FileManagerGrid";
import { FileManagerSidebar } from "./FileManagerSidebar";
import { FileManagerContextMenu } from "./FileManagerContextMenu";
import { useFileSelection } from "./hooks/useFileSelection";
import { useDragAndDrop } from "./hooks/useDragAndDrop";
import { WindowManager, useWindowManager } from "./components/WindowManager";
import { FileWindow } from "./components/FileWindow";
import { DiffWindow } from "./components/DiffWindow";
import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
import { FileManagerGrid } from "./FileManagerGrid.tsx";
import { FileManagerSidebar } from "./FileManagerSidebar.tsx";
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
import { useFileSelection } from "./hooks/useFileSelection.ts";
import { useDragAndDrop } from "./hooks/useDragAndDrop.ts";
import {
WindowManager,
useWindowManager,
} from "./components/WindowManager.tsx";
import { FileWindow } from "./components/FileWindow.tsx";
import { DiffWindow } from "./components/DiffWindow.tsx";
import { useDragToDesktop } from "../../../../hooks/useDragToDesktop.ts";
import { useDragToSystemDesktop } from "../../../../hooks/useDragToSystemDesktop.ts";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog";
import { CompressDialog } from "./components/CompressDialog";
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
import { CompressDialog } from "./components/CompressDialog.tsx";
import {
Upload,
FolderPlus,
@@ -27,7 +30,7 @@ import {
Grid3X3,
List,
} from "lucide-react";
import { TerminalWindow } from "./components/TerminalWindow";
import { TerminalWindow } from "./components/TerminalWindow.tsx";
import type { SSHHost, FileItem } from "../../../types/index.js";
import {
listSSHFiles,
@@ -55,7 +58,7 @@ import {
extractSSHArchive,
compressSSHFiles,
} from "@/ui/main-axios.ts";
import type { SidebarItem } from "./FileManagerSidebar";
import type { SidebarItem } from "./FileManagerSidebar.tsx";
interface FileManagerProps {
initialHost?: SSHHost | null;
@@ -336,12 +339,19 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
credentialId: currentHost.credentialId,
userId: currentHost.userId,
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
});
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sessionId);
setTotpPrompt(result.prompt || "Verification code:");
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
setIsLoading(false);
return;
}
@@ -579,7 +589,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
t("fileManager.sshConnectionFailed", {
name: currentHost?.name,
ip: currentHost?.ip,
port: currentHost?.port,
}),
);
} else {
toast.error(t("fileManager.failedToUploadFile"));
@@ -626,7 +640,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
t("fileManager.sshConnectionFailed", {
name: currentHost?.name,
ip: currentHost?.ip,
port: currentHost?.port,
}),
);
} else {
toast.error(t("fileManager.failedToDownloadFile"));
@@ -760,14 +778,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
if (!status.connected) {
const result = await connectSSH(currentSessionId, {
hostId: currentHost.id,
host: currentHost.ip,
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
authType: currentHost.authType,
password: currentHost.password,
key: currentHost.key,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword,
credentialId: currentHost.credentialId,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
});
if (!result.success) {
@@ -1313,6 +1338,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
authType: currentHost.authType,
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,
});
}
} catch (error) {
@@ -1464,13 +1496,19 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
authType: credentials.password ? "password" : "key",
credentialId: currentHost.credentialId,
userId: currentHost.userId,
userProvidedPassword: true,
jumpHosts: currentHost.jumpHosts,
useSocks5: currentHost.useSocks5,
socks5Host: currentHost.socks5Host,
socks5Port: currentHost.socks5Port,
socks5Username: currentHost.socks5Username,
socks5Password: currentHost.socks5Password,
socks5ProxyChain: currentHost.socks5ProxyChain,
});
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sessionId);
setTotpPrompt(result.prompt || "Verification code:");
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
setIsLoading(false);
return;
}
@@ -1912,11 +1950,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
return (
<div className="h-full flex flex-col bg-dark-bg">
<div className="flex-shrink-0 border-b border-dark-border">
<div className="h-full flex flex-col bg-canvas">
<div className="flex-shrink-0 border-b border-edge">
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2">
<h2 className="font-semibold text-white">{currentHost.name}</h2>
<h2 className="font-semibold text-foreground">
{currentHost.name}
</h2>
<span className="text-sm text-muted-foreground">
{currentHost.ip}:{currentHost.port}
</span>
@@ -1929,11 +1969,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
placeholder={t("fileManager.searchFiles")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 w-48 h-9 bg-dark-bg-button border-dark-border"
className="pl-8 w-48 h-9 bg-button border-edge"
/>
</div>
<div className="flex border border-dark-border rounded-md">
<div className="flex border border-edge rounded-md">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils.ts";
import {
Download,
Edit3,
@@ -20,7 +20,7 @@ import {
FileArchive,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { Kbd, KbdGroup } from "@/components/ui/kbd.tsx";
interface FileItem {
name: string;
@@ -513,7 +513,7 @@ export function FileManagerContextMenu({
<div
data-context-menu
className={cn(
"fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden",
"fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden",
)}
style={{
left: menuPosition.x,
@@ -525,7 +525,7 @@ export function FileManagerContextMenu({
return (
<div
key={`separator-${index}`}
className="border-t border-dark-border"
className="border-t border-edge"
/>
);
}
@@ -535,7 +535,7 @@ export function FileManagerContextMenu({
key={index}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center justify-between",
"hover:bg-dark-hover transition-colors",
"hover:bg-hover transition-colors",
"first:rounded-t-lg last:rounded-b-lg",
item.disabled && "opacity-50 cursor-not-allowed",
item.danger && "text-red-400 hover:bg-red-500/10",

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils.ts";
import {
Folder,
File,
@@ -873,14 +873,14 @@ export function FileManagerGrid({
]);
return (
<div className="h-full flex flex-col bg-dark-bg overflow-hidden relative">
<div className="flex-shrink-0 border-b border-dark-border">
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
<div className="h-full flex flex-col bg-canvas overflow-hidden relative">
<div className="flex-shrink-0 border-b border-edge">
<div className="flex items-center gap-1 p-2 border-b border-edge">
<button
onClick={goBack}
disabled={historyIndex <= 0}
className={cn(
"p-1 rounded hover:bg-dark-hover",
"p-1 rounded hover:bg-hover",
historyIndex <= 0 && "opacity-50 cursor-not-allowed",
)}
title={t("common.back")}
@@ -891,7 +891,7 @@ export function FileManagerGrid({
onClick={goForward}
disabled={historyIndex >= navigationHistory.length - 1}
className={cn(
"p-1 rounded hover:bg-dark-hover",
"p-1 rounded hover:bg-hover",
historyIndex >= navigationHistory.length - 1 &&
"opacity-50 cursor-not-allowed",
)}
@@ -903,7 +903,7 @@ export function FileManagerGrid({
onClick={goUp}
disabled={currentPath === "/"}
className={cn(
"p-1 rounded hover:bg-dark-hover",
"p-1 rounded hover:bg-hover",
currentPath === "/" && "opacity-50 cursor-not-allowed",
)}
title={t("fileManager.parentDirectory")}
@@ -912,7 +912,7 @@ export function FileManagerGrid({
</button>
<button
onClick={onRefresh}
className="p-1 rounded hover:bg-dark-hover"
className="p-1 rounded hover:bg-hover"
title={t("common.refresh")}
>
<RefreshCw className="w-4 h-4" />
@@ -933,7 +933,7 @@ export function FileManagerGrid({
cancelEditingPath();
}
}}
className="flex-1 px-2 py-1 bg-dark-hover border border-dark-border rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary"
className="flex-1 px-2 py-1 bg-hover border border-edge rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={t("fileManager.enterPath")}
autoFocus
/>
@@ -973,7 +973,7 @@ export function FileManagerGrid({
))}
<button
onClick={startEditingPath}
className="ml-2 p-1 rounded hover:bg-dark-hover opacity-60 hover:opacity-100 flex items-center justify-center"
className="ml-2 p-1 rounded hover:bg-hover opacity-60 hover:opacity-100 flex items-center justify-center"
title={t("fileManager.editPath")}
>
<Edit className="w-3 h-3" />
@@ -1091,7 +1091,7 @@ export function FileManagerGrid({
onKeyDown={handleEditKeyDown}
onBlur={handleEditConfirm}
className={cn(
"max-w-[120px] min-w-[60px] w-fit rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs shadow-xs transition-[color,box-shadow] outline-none",
"max-w-[120px] min-w-[60px] w-fit rounded-md border border-edge bg-elevated px-2 py-1 text-xs shadow-xs transition-[color,box-shadow] outline-none",
"text-center text-foreground placeholder:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
)}
@@ -1180,7 +1180,7 @@ export function FileManagerGrid({
onKeyDown={handleEditKeyDown}
onBlur={handleEditConfirm}
className={cn(
"flex-1 min-w-0 max-w-[200px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none",
"flex-1 min-w-0 max-w-[200px] rounded-md border border-edge bg-elevated px-2 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none",
"text-foreground placeholder:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
)}
@@ -1246,7 +1246,7 @@ export function FileManagerGrid({
</div>
</div>
<div className="flex-shrink-0 border-t border-dark-border px-4 py-2 text-xs text-muted-foreground">
<div className="flex-shrink-0 border-t border-edge px-4 py-2 text-xs text-muted-foreground">
<div className="flex justify-between items-center">
<span>{t("fileManager.itemCount", { count: files.length })}</span>
{selectedFiles.length > 0 && (
@@ -1320,7 +1320,7 @@ export function FileManagerGrid({
document.body,
)}
<SimpleLoader visible={isLoading} message={t("common.loading")} />
<SimpleLoader visible={isLoading} message={t("common.connecting")} />
</div>
);
}
@@ -1370,7 +1370,7 @@ function CreateIntentGridItem({
onChange={(e) => setInputName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())}
className="w-full max-w-[120px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-center text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
className="w-full max-w-[120px] rounded-md border border-edge bg-elevated px-2 py-1 text-xs text-center text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
placeholder={
intent.type === "directory"
? t("fileManager.folderName")
@@ -1426,7 +1426,7 @@ function CreateIntentListItem({
onChange={(e) => setInputName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())}
className="flex-1 min-w-0 max-w-[200px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
className="flex-1 min-w-0 max-w-[200px] rounded-md border border-edge bg-elevated px-2 py-1 text-sm text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
placeholder={
intent.type === "directory"
? t("fileManager.folderName")

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils.ts";
import {
ChevronRight,
ChevronDown,
@@ -11,7 +11,7 @@ import {
FolderOpen,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types/index";
import type { SSHHost } from "@/types";
import {
getRecentFiles,
getPinnedFiles,
@@ -375,9 +375,9 @@ export function FileManagerSidebar({
<div key={item.id}>
<div
className={cn(
"flex items-center gap-2 py-1.5 text-sm cursor-pointer hover:bg-dark-hover rounded",
"flex items-center gap-2 py-1.5 text-sm cursor-pointer hover:bg-hover rounded",
isActive && "bg-primary/20 text-primary",
"text-white",
"text-foreground",
)}
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
onClick={() => handleItemClick(item)}
@@ -397,7 +397,7 @@ export function FileManagerSidebar({
e.stopPropagation();
toggleFolder(item.id, item.path);
}}
className="p-0.5 hover:bg-dark-hover rounded"
className="p-0.5 hover:bg-hover rounded"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
@@ -454,7 +454,7 @@ export function FileManagerSidebar({
return (
<>
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
<div className="h-full flex flex-col bg-canvas border-r border-edge">
<div className="flex-1 relative overflow-hidden">
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
{renderSection(
@@ -474,9 +474,7 @@ export function FileManagerSidebar({
)}
<div
className={cn(
hasQuickAccessItems && "pt-4 border-t border-dark-border",
)}
className={cn(hasQuickAccessItems && "pt-4 border-t border-edge")}
>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<Folder className="w-3 h-3" />
@@ -495,7 +493,7 @@ export function FileManagerSidebar({
<div className="fixed inset-0 z-40" />
<div
data-sidebar-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[160px] z-50 overflow-hidden"
className="fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[160px] z-50 overflow-hidden"
style={{
left: contextMenu.x,
top: contextMenu.y,
@@ -504,7 +502,7 @@ export function FileManagerSidebar({
{contextMenu.item.type === "recent" && (
<>
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-foreground first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleRemoveRecentFile(contextMenu.item!);
closeContextMenu();
@@ -519,9 +517,9 @@ export function FileManagerSidebar({
</button>
{recentItems.length > 1 && (
<>
<div className="border-t border-dark-border" />
<div className="border-t border-edge" />
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-red-400 hover:bg-red-500/10 first:rounded-t-lg last:rounded-b-lg"
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-red-400 hover:bg-red-500/10 first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleClearAllRecent();
closeContextMenu();
@@ -541,7 +539,7 @@ export function FileManagerSidebar({
{contextMenu.item.type === "pinned" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-foreground first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleUnpinFile(contextMenu.item!);
closeContextMenu();
@@ -556,7 +554,7 @@ export function FileManagerSidebar({
{contextMenu.item.type === "shortcut" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-foreground first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleRemoveShortcut(contextMenu.item!);
closeContextMenu();

View File

@@ -6,17 +6,17 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
} from "@/components/ui/select.tsx";
import { useTranslation } from "react-i18next";
interface CompressDialogProps {
@@ -71,7 +71,7 @@ export function CompressDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle>{t("fileManager.compressFiles")}</DialogTitle>
<DialogDescription className="text-muted-foreground">
@@ -123,8 +123,8 @@ export function CompressDialog({
</Select>
</div>
<div className="rounded-md bg-dark-hover/50 border border-dark-border p-3">
<p className="text-sm text-gray-400 mb-2">
<div className="rounded-md bg-hover/50 border border-edge p-3">
<p className="text-sm text-muted-foreground mb-2">
{t("fileManager.selectedFiles")}:
</p>
<ul className="text-sm space-y-1">
@@ -134,7 +134,7 @@ export function CompressDialog({
</li>
))}
{fileNames.length > 5 && (
<li className="text-gray-400 italic">
<li className="text-muted-foreground italic">
{t("fileManager.andMoreFiles", {
count: fileNames.length - 5,
})}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
@@ -16,8 +16,8 @@ import {
downloadSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import type { FileItem, SSHHost } from "@/types/index";
} from "@/ui/main-axios.ts";
import type { FileItem, SSHHost } from "@/types";
interface DiffViewerProps {
file1: FileItem;
@@ -210,7 +210,7 @@ export function DiffViewer({
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-dark-bg">
<div className="h-full flex items-center justify-center bg-canvas">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
@@ -223,7 +223,7 @@ export function DiffViewer({
if (error) {
return (
<div className="h-full flex items-center justify-center bg-dark-bg">
<div className="h-full flex items-center justify-center bg-canvas">
<div className="text-center max-w-md">
<FileText className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
<p className="text-red-500 mb-4">{error}</p>
@@ -237,8 +237,8 @@ export function DiffViewer({
}
return (
<div className="h-full flex flex-col bg-dark-bg">
<div className="flex-shrink-0 border-b border-dark-border p-3">
<div className="h-full flex flex-col bg-canvas">
<div className="flex-shrink-0 border-b border-edge p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm">

View File

@@ -1,7 +1,7 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { DiffViewer } from "./DiffViewer";
import { useWindowManager } from "./WindowManager";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { DiffViewer } from "./DiffViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import type { FileItem, SSHHost } from "../../../../types/index.js";

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils.ts";
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
import {
FileText,
@@ -45,7 +45,7 @@ import {
SiMysql,
SiDocker,
} from "react-icons/si";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button.tsx";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
@@ -133,14 +133,14 @@ function getLanguageIcon(filename: string): React.ReactNode {
yml: <SiYaml className="w-6 h-6 text-red-400" />,
toml: <SiToml className="w-6 h-6 text-orange-400" />,
sql: <SiMysql className="w-6 h-6 text-blue-500" />,
sh: <SiGnubash className="w-6 h-6 text-gray-700" />,
bash: <SiGnubash className="w-6 h-6 text-gray-700" />,
zsh: <SiShell className="w-6 h-6 text-gray-700" />,
sh: <SiGnubash className="w-6 h-6 text-foreground" />,
bash: <SiGnubash className="w-6 h-6 text-foreground" />,
zsh: <SiShell className="w-6 h-6 text-foreground" />,
vue: <SiVuedotjs className="w-6 h-6 text-green-500" />,
svelte: <SiSvelte className="w-6 h-6 text-orange-500" />,
md: <SiMarkdown className="w-6 h-6 text-gray-600" />,
conf: <SiShell className="w-6 h-6 text-gray-600" />,
ini: <Code className="w-6 h-6 text-gray-600" />,
md: <SiMarkdown className="w-6 h-6 text-muted-foreground" />,
conf: <SiShell className="w-6 h-6 text-muted-foreground" />,
ini: <Code className="w-6 h-6 text-muted-foreground" />,
};
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
@@ -238,7 +238,7 @@ function getFileType(filename: string): {
return {
type: "unknown",
icon: <FileIcon className="w-6 h-6" />,
color: "text-gray-500",
color: "text-foreground-subtle",
};
}
}
@@ -329,6 +329,22 @@ export function FileViewer({
const fileTypeInfo = getFileType(file.name);
const getImageDataUrl = (content: string, fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") {
try {
const base64 = btoa(unescape(encodeURIComponent(content)));
return `data:image/svg+xml;base64,${base64}`;
} catch (e) {
console.error("Failed to encode SVG:", e);
return "";
}
}
return `data:image/*;base64,${content}`;
};
const WARNING_SIZE = 50 * 1024 * 1024;
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
@@ -353,15 +369,6 @@ export function FileViewer({
} else {
setShowLargeFileWarning(false);
}
if (
fileTypeInfo.type === "image" &&
file.name.toLowerCase().endsWith(".svg") &&
content
) {
setImageLoading(false);
setImageLoadError(false);
}
}, [
content,
savedContent,
@@ -423,7 +430,7 @@ export function FileViewer({
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-sm text-gray-600">Loading file...</p>
<p className="text-sm text-muted-foreground">Loading file...</p>
</div>
</div>
);
@@ -716,21 +723,11 @@ export function FileViewer({
</Button>
)}
</div>
) : file.name.toLowerCase().endsWith(".svg") ? (
<div
className="max-w-full max-h-full flex items-center justify-center"
style={{ maxHeight: "calc(100vh - 200px)" }}
dangerouslySetInnerHTML={{ __html: content }}
onLoad={() => {
setImageLoading(false);
setImageLoadError(false);
}}
/>
) : (
<PhotoProvider maskOpacity={0.7}>
<PhotoView src={`data:image/*;base64,${content}`}>
<PhotoView src={getImageDataUrl(content, file.name)}>
<img
src={`data:image/*;base64,${content}`}
src={getImageDataUrl(content, file.name)}
alt={file.name}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: "calc(100vh - 200px)" }}
@@ -812,6 +809,9 @@ export function FileViewer({
},
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
},
".cm-editor": {
height: "100%",
@@ -835,7 +835,7 @@ export function FileViewer({
}}
/>
) : (
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto thin-scrollbar bg-background text-foreground">
{editedContent || content || t("fileManager.fileIsEmpty")}
</div>
)}
@@ -969,7 +969,7 @@ export function FileViewer({
</div>
</div>
<div className="flex-1 overflow-auto bg-muted/10">
<div className="flex-1 overflow-auto thin-scrollbar bg-muted/10">
<div className="p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@@ -1041,7 +1041,7 @@ export function FileViewer({
</blockquote>
),
table: ({ children }) => (
<div className="mb-3 overflow-x-auto">
<div className="mb-3 overflow-x-auto thin-scrollbar">
<table className="min-w-full border border-border rounded-lg text-sm">
{children}
</table>
@@ -1084,7 +1084,7 @@ export function FileViewer({
</div>
</>
) : (
<div className="flex-1 overflow-auto p-6">
<div className="flex-1 overflow-auto thin-scrollbar p-6">
<div className="max-w-4xl mx-auto">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
@@ -1154,7 +1154,7 @@ export function FileViewer({
</blockquote>
),
table: ({ children }) => (
<div className="mb-4 overflow-x-auto">
<div className="mb-4 overflow-x-auto thin-scrollbar">
<table className="min-w-full border border-border rounded-lg">
{children}
</table>
@@ -1252,7 +1252,7 @@ export function FileViewer({
</div>
</div>
<div className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
<div className="flex-1 overflow-auto thin-scrollbar p-6 bg-surface">
<div className="flex justify-center">
{pdfError ? (
<div className="text-center text-muted-foreground p-8">

View File

@@ -1,14 +1,14 @@
import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow";
import { FileViewer } from "./FileViewer";
import { useWindowManager } from "./WindowManager";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { FileViewer } from "./FileViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import {
downloadSSHFile,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";

View File

@@ -6,11 +6,11 @@ import {
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Input } from "@/components/ui/input.tsx";
import { useTranslation } from "react-i18next";
import { Shield } from "lucide-react";
@@ -140,7 +140,7 @@ export function PermissionsDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
@@ -155,7 +155,7 @@ export function PermissionsDialog({
<div className="space-y-6 py-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<Label className="text-gray-400">
<Label className="text-muted-foreground">
{t("fileManager.currentPermissions")}
</Label>
<p className="font-mono text-lg mt-1">
@@ -163,7 +163,7 @@ export function PermissionsDialog({
</p>
</div>
<div>
<Label className="text-gray-400">
<Label className="text-muted-foreground">
{t("fileManager.newPermissions")}
</Label>
<p className="font-mono text-lg mt-1">{octal}</p>

View File

@@ -1,7 +1,7 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal";
import { useWindowManager } from "./WindowManager";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
interface SSHHost {
@@ -60,6 +60,14 @@ export function TerminalWindow({
const handleMaximize = () => {
maximizeWindow(windowId);
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 150);
};
const handleFocus = () => {

View File

@@ -0,0 +1,850 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
getServerStatusById,
getServerMetricsById,
startMetricsPolling,
stopMetricsPolling,
submitMetricsTOTP,
executeSnippet,
logActivity,
type ServerMetrics,
} from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
type WidgetType,
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets.ts";
import {
CpuWidget,
MemoryWidget,
DiskWidget,
NetworkWidget,
UptimeWidget,
ProcessesWidget,
SystemWidget,
LoginStatsWidget,
} from "./widgets";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface QuickAction {
name: string;
snippetId: number;
}
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
quickActions?: QuickAction[];
statsConfig?: string | StatsConfig;
[key: string]: unknown;
}
interface TabData {
id: number;
type: string;
title?: string;
hostConfig?: HostConfig;
[key: string]: unknown;
}
interface ServerProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
export function ServerStats({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ServerProps): React.ReactElement {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const { addTab, tabs, currentTab, removeTab } = useTabs() as {
addTab: (tab: { type: string; [key: string]: unknown }) => number;
tabs: TabData[];
currentTab: number | null;
removeTab: (tabId: number) => void;
};
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
"offline",
);
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [metricsHistory, setMetricsHistory] = React.useState<ServerMetrics[]>(
[],
);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
new Set(),
);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [totpVerified, setTotpVerified] = React.useState(false);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
return DEFAULT_STATS_CONFIG;
}
try {
const parsed =
typeof currentHostConfig.statsConfig === "string"
? JSON.parse(currentHostConfig.statsConfig)
: currentHostConfig.statsConfig;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch (error) {
console.error("Failed to parse statsConfig:", error);
return DEFAULT_STATS_CONFIG;
}
}, [currentHostConfig?.statsConfig]);
const enabledWidgets = statsConfig.enabledWidgets;
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const metricsEnabled = statsConfig.metricsEnabled !== false;
React.useEffect(() => {
const handleVisibilityChange = () => {
setIsPageVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
const isActuallyVisible = isVisible && isPageVisible;
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const heartbeatInterval = setInterval(async () => {
try {
const { sendMetricsHeartbeat } = await import("@/ui/main-axios.ts");
await sendMetricsHeartbeat(viewerSessionId);
} catch (error) {
console.error("Failed to send heartbeat:", error);
}
}, 30000);
return () => clearInterval(heartbeatInterval);
}, [viewerSessionId, isActuallyVisible]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setServerStatus("offline");
setMetrics(null);
setMetricsHistory([]);
setShowStatsUI(true);
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
const logServerActivity = async () => {
if (
!currentHostConfig?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName =
currentHostConfig.name ||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
await logActivity("server_stats", currentHostConfig.id, hostName);
} catch (err) {
console.warn("Failed to log server stats activity:", err);
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
};
const handleTOTPSubmit = async (totpCode: string) => {
if (!totpSessionId || !currentHostConfig) return;
try {
const result = await submitMetricsTOTP(totpSessionId, totpCode);
if (result.success) {
setTotpRequired(false);
setTotpSessionId(null);
setShowStatsUI(true);
setTotpVerified(true);
if (result.viewerSessionId) {
setViewerSessionId(result.viewerSessionId);
}
} else {
toast.error(t("serverStats.totpFailed"));
}
} catch (error) {
toast.error(t("serverStats.totpFailed"));
console.error("TOTP verification failed:", error);
}
};
const handleTOTPCancel = async () => {
setTotpRequired(false);
if (currentHostConfig?.id) {
try {
await stopMetricsPolling(currentHostConfig.id);
} catch (error) {
console.error("Failed to stop metrics polling:", error);
}
}
if (currentTab !== null) {
removeTab(currentTab);
}
};
const renderWidget = (widgetType: WidgetType) => {
switch (widgetType) {
case "cpu":
return <CpuWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "memory":
return (
<MemoryWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "disk":
return <DiskWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "network":
return (
<NetworkWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "uptime":
return (
<UptimeWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "processes":
return (
<ProcessesWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "system":
return (
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "login_stats":
return (
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
);
default:
return null;
}
};
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
React.useEffect(() => {
if (!statusCheckEnabled || !currentHostConfig?.id) {
setServerStatus("offline");
return;
}
let cancelled = false;
let intervalId: number | undefined;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) {
setServerStatus(res?.status === "online" ? "online" : "offline");
}
} catch (error: unknown) {
if (!cancelled) {
const err = error as {
response?: { status?: number };
};
if (err?.response?.status === 503) {
setServerStatus("offline");
} else if (err?.response?.status === 504) {
setServerStatus("offline");
} else if (err?.response?.status === 404) {
setServerStatus("offline");
} else {
setServerStatus("offline");
}
}
}
};
fetchStatus();
intervalId = window.setInterval(
fetchStatus,
statsConfig.statusCheckInterval * 1000,
);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [
currentHostConfig?.id,
statusCheckEnabled,
statsConfig.statusCheckInterval,
]);
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id) {
return;
}
let cancelled = false;
let pollingIntervalId: number | undefined;
let debounceTimeout: NodeJS.Timeout | undefined;
if (isActuallyVisible && !metrics) {
setIsLoadingMetrics(true);
setShowStatsUI(true);
} else if (!isActuallyVisible) {
setIsLoadingMetrics(false);
}
const startMetrics = async () => {
if (cancelled) return;
if (currentHostConfig.authType === "none") {
toast.error(t("serverStats.noneAuthNotSupported"));
setIsLoadingMetrics(false);
if (currentTab !== null) {
removeTab(currentTab);
}
return;
}
const hasExistingMetrics = metrics !== null;
if (!hasExistingMetrics) {
setIsLoadingMetrics(true);
}
setShowStatsUI(true);
try {
if (!totpVerified) {
const result = await startMetricsPolling(currentHostConfig.id);
if (cancelled) return;
if (result.requires_totp) {
setTotpRequired(true);
setTotpSessionId(result.sessionId || null);
setTotpPrompt(result.prompt || "Verification code");
setIsLoadingMetrics(false);
return;
}
if (result.viewerSessionId) {
setViewerSessionId(result.viewerSessionId);
}
}
let retryCount = 0;
let data = null;
const maxRetries = 15;
const retryDelay = 2000;
while (retryCount < maxRetries && !cancelled) {
try {
data = await getServerMetricsById(currentHostConfig.id);
break;
} catch (error: any) {
retryCount++;
if (retryCount === 1) {
const initialDelay = totpVerified ? 3000 : 5000;
await new Promise((resolve) => setTimeout(resolve, initialDelay));
} else if (retryCount < maxRetries && !cancelled) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} else {
throw error;
}
}
}
if (cancelled) return;
if (data) {
setMetrics(data);
if (!hasExistingMetrics) {
setIsLoadingMetrics(false);
logServerActivity();
}
}
pollingIntervalId = window.setInterval(async () => {
if (cancelled) return;
try {
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
return newHistory.slice(-20);
});
}
} catch (error) {
if (!cancelled) {
console.error("Failed to fetch metrics:", error);
}
}
}, statsConfig.metricsInterval * 1000);
} catch (error) {
if (!cancelled) {
console.error("Failed to start metrics polling:", error);
setIsLoadingMetrics(false);
toast.error(t("serverStats.failedToFetchMetrics"));
if (currentTab !== null) {
removeTab(currentTab);
}
}
}
};
const stopMetrics = async () => {
if (pollingIntervalId) {
window.clearInterval(pollingIntervalId);
pollingIntervalId = undefined;
}
if (currentHostConfig?.id) {
try {
await stopMetricsPolling(
currentHostConfig.id,
viewerSessionId || undefined,
);
} catch (error) {
console.error("Failed to stop metrics polling:", error);
}
}
};
debounceTimeout = setTimeout(() => {
if (isActuallyVisible) {
startMetrics();
} else {
stopMetrics();
}
}, 500);
return () => {
cancelled = true;
if (debounceTimeout) clearTimeout(debounceTimeout);
if (pollingIntervalId) window.clearInterval(pollingIntervalId);
if (currentHostConfig?.id) {
stopMetricsPolling(currentHostConfig.id).catch(() => {});
}
};
}, [
currentHostConfig?.id,
isActuallyVisible,
metricsEnabled,
statsConfig.metricsInterval,
totpVerified,
]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
const bottomMarginPx = 8;
const isFileManagerAlreadyOpen = React.useMemo(() => {
if (!currentHostConfig) return false;
return tabs.some(
(tab: TabData) =>
tab.type === "file_manager" &&
tab.hostConfig?.id === currentHostConfig.id,
);
}, [tabs, currentHostConfig]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div className="h-full w-full flex flex-col">
{!totpRequired && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
{statusCheckEnabled && (
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
disabled={isRefreshing}
className="font-semibold"
onClick={async () => {
if (currentHostConfig?.id) {
try {
setIsRefreshing(true);
const res = await getServerStatusById(
currentHostConfig.id,
);
setServerStatus(
res?.status === "online" ? "online" : "offline",
);
const data = await getServerMetricsById(
currentHostConfig.id,
);
setMetrics(data);
setShowStatsUI(true);
} catch (error: unknown) {
const err = error as {
code?: string;
status?: number;
response?: {
status?: number;
data?: { error?: string };
};
};
if (
err?.code === "TOTP_REQUIRED" ||
(err?.response?.status === 403 &&
err?.response?.data?.error === "TOTP_REQUIRED")
) {
toast.error(t("serverStats.totpUnavailable"));
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.response?.status === 503 ||
err?.status === 503
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.response?.status === 504 ||
err?.status === 504
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.response?.status === 404 ||
err?.status === 404
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
}
} finally {
setIsRefreshing(false);
}
}
}}
title={t("serverStats.refreshStatusAndMetrics")}
>
{isRefreshing ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-foreground-secondary border-t-transparent rounded-full animate-spin"></div>
{t("serverStats.refreshing")}
</div>
) : (
t("serverStats.refreshStatus")
)}
</Button>
{currentHostConfig?.enableFileManager && (
<Button
variant="outline"
className="font-semibold"
disabled={isFileManagerAlreadyOpen}
title={
isFileManagerAlreadyOpen
? t("serverStats.fileManagerAlreadyOpen")
: t("serverStats.openFileManager")
}
onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase =
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: "file_manager",
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
{t("nav.fileManager")}
</Button>
)}
{currentHostConfig?.enableDocker && (
<Button
variant="outline"
className="font-semibold"
onClick={() => {
const titleBase =
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: "docker",
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
{t("nav.docker")}
</Button>
)}
</div>
</div>
)}
{!totpRequired && <Separator className="p-0.25 w-full" />}
<div className="flex-1 overflow-y-auto min-h-0 thin-scrollbar relative">
{(metricsEnabled && showStatsUI) ||
(currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0) ? (
<div className="border-edge m-1 p-2 overflow-y-auto thin-scrollbar flex-1 flex flex-col">
{currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && (
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
{t("serverStats.quickActions")}
</h3>
<div className="flex flex-wrap gap-2">
{currentHostConfig.quickActions.map((action, index) => {
const isExecuting = executingActions.has(
action.snippetId,
);
return (
<Button
key={index}
variant="outline"
size="sm"
className="font-semibold"
disabled={isExecuting}
onClick={async () => {
if (!currentHostConfig) return;
setExecutingActions((prev) =>
new Set(prev).add(action.snippetId),
);
toast.loading(
t("serverStats.executingQuickAction", {
name: action.name,
}),
{ id: `quick-action-${action.snippetId}` },
);
try {
const result = await executeSnippet(
action.snippetId,
currentHostConfig.id,
);
if (result.success) {
toast.success(
t("serverStats.quickActionSuccess", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description: result.output
? result.output.substring(0, 200)
: undefined,
duration: 5000,
},
);
} else {
toast.error(
t("serverStats.quickActionFailed", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
result.error ||
result.output ||
undefined,
duration: 5000,
},
);
}
} catch (error: any) {
toast.error(
t("serverStats.quickActionError", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
error?.message || "Unknown error",
duration: 5000,
},
);
} finally {
setExecutingActions((prev) => {
const next = new Set(prev);
next.delete(action.snippetId);
return next;
});
}
}}
title={t("serverStats.executeQuickAction", {
name: action.name,
})}
>
{isExecuting ? (
<div className="flex items-center gap-2">
<div className="w-3 h-3 border-2 border-foreground-secondary border-t-transparent rounded-full animate-spin"></div>
{action.name}
</div>
) : (
action.name
)}
</Button>
);
})}
</div>
</div>
)}
{metricsEnabled &&
showStatsUI &&
!isLoadingMetrics &&
(!metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div>
<p className="text-foreground-secondary mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-foreground-subtle">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
) : metrics ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{enabledWidgets.map((widgetType) => (
<div key={widgetType} className="h-[280px]">
{renderWidget(widgetType)}
</div>
))}
</div>
) : null)}
</div>
) : null}
{metricsEnabled && (
<SimpleLoader
visible={isLoadingMetrics && !metrics}
message={t("serverStats.connecting")}
/>
)}
</div>
</div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTOTPSubmit}
onCancel={handleTOTPCancel}
backgroundColor="var(--bg-canvas)"
/>
</div>
);
}

View File

@@ -30,10 +30,10 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
}, [metricsHistory]);
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.cpuUsage")}
</h3>
</div>
@@ -45,13 +45,13 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
? `${metrics.cpu.percent}%`
: "N/A"}
</div>
<div className="text-xs text-gray-400">
<div className="text-xs text-muted-foreground">
{typeof metrics?.cpu?.cores === "number"
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
: t("serverStats.naCpus")}
</div>
</div>
<div className="text-xs text-gray-500 flex-shrink-0">
<div className="text-xs text-foreground-subtle flex-shrink-0">
{metrics?.cpu?.load
? t("serverStats.loadAverage", {
avg1: metrics.cpu.load[0].toFixed(2),
@@ -62,7 +62,10 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<LineChart
data={chartData}
margin={{ top: 5, right: 5, left: -25, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="index"

View File

@@ -0,0 +1,125 @@
import React from "react";
import { HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
const {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} = RechartsPrimitive;
interface DiskWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
const { t } = useTranslation();
const chartData = React.useMemo(() => {
return metricsHistory.map((m, index) => ({
index,
disk: m.disk?.percent || 0,
}));
}, [metricsHistory]);
return (
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.diskUsage")}
</h3>
</div>
<div className="flex flex-col flex-1 min-h-0 gap-2">
<div className="flex items-baseline gap-3 flex-shrink-0">
<div className="text-2xl font-bold text-orange-400">
{typeof metrics?.disk?.percent === "number"
? `${metrics.disk.percent}%`
: "N/A"}
</div>
<div className="text-xs text-muted-foreground">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
if (used && total) {
return `${used} / ${total}`;
}
return "N/A";
})()}
</div>
</div>
<div className="text-xs text-foreground-subtle flex-shrink-0">
{(() => {
const available = metrics?.disk?.availableHuman;
return available
? `${t("serverStats.available")}: ${available}`
: `${t("serverStats.available")}: N/A`;
})()}
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 5, right: 5, left: -25, bottom: 5 }}
>
<defs>
<linearGradient id="diskGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#fb923c" stopOpacity={0.8} />
<stop offset="95%" stopColor="#fb923c" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="index"
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
hide
/>
<YAxis
domain={[0, 100]}
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "1px solid #374151",
borderRadius: "6px",
color: "#fff",
}}
formatter={(value: number) => [`${value.toFixed(1)}%`, "Disk"]}
cursor={{
stroke: "#fb923c",
strokeWidth: 1,
strokeDasharray: "3 3",
}}
/>
<Area
type="monotone"
dataKey="disk"
stroke="#fb923c"
strokeWidth={2}
fill="url(#diskGradient)"
animationDuration={300}
activeDot={{
r: 4,
fill: "#fb923c",
stroke: "#fff",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}

View File

@@ -35,18 +35,18 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
const uniqueIPs = loginStats?.uniqueIPs || 0;
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<UserCheck className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.loginStats")}
</h3>
</div>
<div className="flex flex-col flex-1 min-h-0 gap-3">
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
<div className="bg-canvas/40 p-2 rounded border border-edge/30 hover:bg-canvas/50">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Activity className="h-3 w-3" />
<span>{t("serverStats.totalLogins")}</span>
</div>
@@ -54,8 +54,8 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
{totalLogins}
</div>
</div>
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
<div className="bg-canvas/40 p-2 rounded border border-edge/30 hover:bg-canvas/50">
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<MapPin className="h-3 w-3" />
<span>{t("serverStats.uniqueIPs")}</span>
</div>
@@ -63,16 +63,16 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
<div className="flex-1 min-h-0 overflow-y-auto thin-scrollbar space-y-2">
<div className="flex-shrink-0">
<div className="flex items-center gap-2 mb-1">
<UserCheck className="h-4 w-4 text-green-400" />
<span className="text-sm font-semibold text-gray-300">
<span className="text-sm font-semibold text-foreground-secondary">
{t("serverStats.recentSuccessfulLogins")}
</span>
</div>
{recentLogins.length === 0 ? (
<div className="text-xs text-gray-500 italic p-2">
<div className="text-xs text-foreground-subtle italic p-2">
{t("serverStats.noRecentLoginData")}
</div>
) : (
@@ -80,20 +80,20 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
{recentLogins.slice(0, 5).map((login, idx) => (
<div
key={idx}
className="text-xs bg-dark-bg-darker p-2 rounded border border-dark-border/30 flex justify-between items-center"
className="text-xs bg-canvas/40 p-2 rounded border border-edge/30 hover:bg-canvas/50 flex justify-between items-center"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-green-400 font-mono truncate">
{login.user}
</span>
<span className="text-gray-500">
<span className="text-foreground-subtle">
{t("serverStats.from")}
</span>
<span className="text-blue-400 font-mono truncate">
{login.ip}
</span>
</div>
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
<span className="text-foreground-subtle text-[10px] flex-shrink-0 ml-2">
{new Date(login.time).toLocaleString()}
</span>
</div>
@@ -106,28 +106,28 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
<div className="flex-shrink-0">
<div className="flex items-center gap-2 mb-1">
<UserX className="h-4 w-4 text-red-400" />
<span className="text-sm font-semibold text-gray-300">
<span className="text-sm font-semibold text-foreground-secondary">
{t("serverStats.recentFailedAttempts")}
</span>
</div>
<div className="space-y-1">
{failedLogins.slice(0, 3).map((login, idx) => (
{failedLogins.slice(0, 3).map((login) => (
<div
key={idx}
key={`failed-${login.user}-${login.time}-${login.ip || "unknown"}`}
className="text-xs bg-red-900/20 p-2 rounded border border-red-500/30 flex justify-between items-center"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-red-400 font-mono truncate">
{login.user}
</span>
<span className="text-gray-500">
<span className="text-foreground-subtle">
{t("serverStats.from")}
</span>
<span className="text-blue-400 font-mono truncate">
{login.ip}
</span>
</div>
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
<span className="text-foreground-subtle text-[10px] flex-shrink-0 ml-2">
{new Date(login.time).toLocaleString()}
</span>
</div>

View File

@@ -30,10 +30,10 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
}, [metricsHistory]);
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.memoryUsage")}
</h3>
</div>
@@ -45,7 +45,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
? `${metrics.memory.percent}%`
: "N/A"}
</div>
<div className="text-xs text-gray-400">
<div className="text-xs text-muted-foreground">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
@@ -56,7 +56,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
})()}
</div>
</div>
<div className="text-xs text-gray-500 flex-shrink-0">
<div className="text-xs text-foreground-subtle flex-shrink-0">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
@@ -69,7 +69,10 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<AreaChart
data={chartData}
margin={{ top: 5, right: 5, left: -25, bottom: 5 }}
>
<defs>
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#34d399" stopOpacity={0.8} />
@@ -99,6 +102,11 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
`${value.toFixed(1)}%`,
"Memory",
]}
cursor={{
stroke: "#34d399",
strokeWidth: 1,
strokeDasharray: "3 3",
}}
/>
<Area
type="monotone"
@@ -107,6 +115,12 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
strokeWidth={2}
fill="url(#memoryGradient)"
animationDuration={300}
activeDot={{
r: 4,
fill: "#34d399",
stroke: "#fff",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>

View File

@@ -24,17 +24,17 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
const interfaces = network?.interfaces || [];
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Network className="h-5 w-5 text-indigo-400" />
<h3 className="font-semibold text-lg text-white">
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.networkInterfaces")}
</h3>
</div>
<div className="space-y-2.5 overflow-auto flex-1">
<div className="space-y-2.5 overflow-auto thin-scrollbar flex-1">
{interfaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<WifiOff className="h-10 w-10 mb-3 opacity-50" />
<p className="text-sm">{t("serverStats.noInterfacesFound")}</p>
</div>
@@ -42,14 +42,14 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
interfaces.map((iface, index: number) => (
<div
key={index}
className="p-3 rounded-lg bg-dark-bg/50 border border-dark-border/30 hover:bg-dark-bg/60 transition-colors"
className="p-3 rounded-lg bg-canvas/40 border border-edge/30 hover:bg-canvas/50"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Wifi
className={`h-4 w-4 ${iface.state === "UP" ? "text-green-400" : "text-gray-500"}`}
className={`h-4 w-4 ${iface.state === "UP" ? "text-green-400" : "text-foreground-subtle"}`}
/>
<span className="text-sm font-semibold text-white font-mono">
<span className="text-sm font-semibold text-foreground font-mono">
{iface.name}
</span>
</div>
@@ -57,13 +57,13 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
className={`text-xs px-2.5 py-0.5 rounded-full font-medium ${
iface.state === "UP"
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-500"
: "bg-surface text-foreground-subtle"
}`}
>
{iface.state}
</span>
</div>
<div className="text-xs text-gray-400 font-mono font-medium">
<div className="text-xs text-muted-foreground font-mono font-medium">
{iface.ip}
</div>
</div>

View File

@@ -28,22 +28,22 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
const topProcesses = processes?.top || [];
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<List className="h-5 w-5 text-yellow-400" />
<h3 className="font-semibold text-lg text-white">
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.processes")}
</h3>
</div>
<div className="flex items-center justify-between mb-3 pb-2 border-b border-dark-border/30">
<div className="text-sm text-gray-400">
<div className="flex items-center justify-between mb-3 pb-2 border-b border-edge/30">
<div className="text-sm text-muted-foreground">
{t("serverStats.totalProcesses")}:{" "}
<span className="text-white font-semibold">
<span className="text-foreground font-semibold">
{processes?.total ?? "N/A"}
</span>
</div>
<div className="text-sm text-gray-400">
<div className="text-sm text-muted-foreground">
{t("serverStats.running")}:{" "}
<span className="text-green-400 font-semibold">
{processes?.running ?? "N/A"}
@@ -51,21 +51,21 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
</div>
</div>
<div className="overflow-auto flex-1">
<div className="overflow-auto thin-scrollbar flex-1">
{topProcesses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Activity className="h-10 w-10 mb-3 opacity-50" />
<p className="text-sm">{t("serverStats.noProcessesFound")}</p>
</div>
) : (
<div className="space-y-2">
{topProcesses.map((proc, index: number) => (
{topProcesses.map((proc, index) => (
<div
key={index}
className="p-2.5 rounded-lg bg-dark-bg/30 hover:bg-dark-bg/50 transition-colors border border-dark-border/20"
className="p-2.5 rounded-lg bg-canvas/40 hover:bg-canvas/50 border border-edge/30"
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-mono text-gray-400 font-medium">
<span className="text-xs font-mono text-muted-foreground font-medium">
PID: {proc.pid}
</span>
<div className="flex gap-3 text-xs font-medium">
@@ -73,10 +73,12 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
<span className="text-green-400">MEM: {proc.mem}%</span>
</div>
</div>
<div className="text-xs text-white font-mono truncate mb-1">
<div className="text-xs text-foreground font-mono truncate mb-1">
{proc.command}
</div>
<div className="text-xs text-gray-500">User: {proc.user}</div>
<div className="text-xs text-foreground-subtle">
User: {proc.user}
</div>
</div>
))}
</div>

View File

@@ -21,10 +21,10 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
const system = metricsWithSystem?.system;
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Server className="h-5 w-5 text-purple-400" />
<h3 className="font-semibold text-lg text-white">
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.systemInfo")}
</h3>
</div>
@@ -33,10 +33,10 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-purple-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-gray-400 mb-1.5">
<p className="text-xs text-muted-foreground mb-1.5">
{t("serverStats.hostname")}
</p>
<p className="text-sm text-white font-mono truncate font-medium">
<p className="text-sm text-foreground font-mono truncate font-medium">
{system?.hostname || "N/A"}
</p>
</div>
@@ -45,10 +45,10 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-purple-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-gray-400 mb-1.5">
<p className="text-xs text-muted-foreground mb-1.5">
{t("serverStats.operatingSystem")}
</p>
<p className="text-sm text-white font-mono truncate font-medium">
<p className="text-sm text-foreground font-mono truncate font-medium">
{system?.os || "N/A"}
</p>
</div>
@@ -57,10 +57,10 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-purple-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-gray-400 mb-1.5">
<p className="text-xs text-muted-foreground mb-1.5">
{t("serverStats.kernel")}
</p>
<p className="text-sm text-white font-mono truncate font-medium">
<p className="text-sm text-foreground font-mono truncate font-medium">
{system?.kernel || "N/A"}
</p>
</div>

View File

@@ -20,10 +20,10 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
const uptime = metricsWithUptime?.uptime;
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="h-full w-full p-4 rounded-lg bg-elevated border border-edge/50 hover:bg-elevated/70 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Clock className="h-5 w-5 text-cyan-400" />
<h3 className="font-semibold text-lg text-white">
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.uptime")}
</h3>
</div>
@@ -39,11 +39,11 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
<div className="text-3xl font-bold text-cyan-400 mb-2">
{uptime?.formatted || "N/A"}
</div>
<div className="text-sm text-gray-400">
<div className="text-sm text-muted-foreground">
{t("serverStats.totalUptime")}
</div>
{uptime?.seconds && (
<div className="text-xs text-gray-500 mt-2">
<div className="text-xs text-foreground-subtle mt-2">
{Math.floor(uptime.seconds).toLocaleString()}{" "}
{t("serverStats.seconds")}
</div>

View File

@@ -0,0 +1,8 @@
export { CpuWidget } from "./CpuWidget.tsx";
export { MemoryWidget } from "./MemoryWidget.tsx";
export { DiskWidget } from "./DiskWidget.tsx";
export { NetworkWidget } from "./NetworkWidget.tsx";
export { UptimeWidget } from "./UptimeWidget.tsx";
export { ProcessesWidget } from "./ProcessesWidget.tsx";
export { SystemWidget } from "./SystemWidget.tsx";
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";

View File

@@ -25,13 +25,16 @@ import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
} from "@/constants/terminal-themes";
} from "@/constants/terminal-themes.ts";
import type { TerminalConfig } from "@/types";
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
import { useTheme } from "@/components/theme-provider.tsx";
import { useCommandTracker } from "@/ui/hooks/useCommandTracker.ts";
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory.ts";
import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
interface HostConfig {
id?: number;
@@ -92,21 +95,35 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const commandHistoryContext = useCommandHistory();
const { confirmWithToast } = useConfirmation();
const { theme: appTheme } = useTheme();
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
const themeColors =
TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
const isDarkMode =
appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
let themeColors;
if (config.theme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[config.theme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const backgroundColor = themeColors.background;
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 [isReady, setIsReady] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isFitted, setIsFitted] = useState(true);
const [isFitted, setIsFitted] = useState(false);
const [, setConnectionError] = useState<string | null>(null);
const [, setIsAuthenticated] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
@@ -127,6 +144,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const shouldNotReconnectRef = useRef(false);
const isReconnectingRef = useRef(false);
const isConnectingRef = useRef(false);
const connectionAttemptIdRef = useRef(0);
const totpAttemptsRef = useRef(0);
const totpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activityLoggedRef = useRef(false);
const keyHandlerAttachedRef = useRef(false);
@@ -212,7 +232,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
useEffect(() => {
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") !== "false";
localStorage.getItem("commandAutocomplete") === "true";
if (hostConfig.id && autocompleteEnabled) {
import("@/ui/main-axios.ts")
@@ -242,6 +262,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}, [autocompleteSelectedIndex]);
const activityLoggingRef = useRef(false);
const sudoPromptShownRef = useRef(false);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
@@ -320,7 +341,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (
!fitAddonRef.current ||
!terminal ||
!isVisibleRef.current ||
!isVisible ||
isFittingRef.current
) {
return;
@@ -354,6 +375,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
function handleTotpSubmit(code: string) {
if (webSocketRef.current && code) {
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
}
webSocketRef.current.send(
JSON.stringify({
type: isPasswordPrompt ? "password_response" : "totp_response",
@@ -367,6 +392,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
function handleTotpCancel() {
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
}
setTotpRequired(false);
setTotpPrompt("");
if (onClose) onClose();
@@ -443,6 +472,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
}
webSocketRef.current?.close();
setIsConnected(false);
setIsConnecting(false);
@@ -512,6 +545,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}),
);
const delay = Math.min(
2000 * Math.pow(2, reconnectAttempts.current - 1),
8000,
);
reconnectTimeoutRef.current = setTimeout(() => {
if (
isUnmountingRef.current ||
@@ -543,7 +581,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
isReconnectingRef.current = false;
}, 2000 * reconnectAttempts.current);
}, delay);
}
function connectToHost(cols: number, rows: number) {
@@ -552,6 +590,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
isConnectingRef.current = true;
connectionAttemptIdRef.current++;
if (!isReconnectingRef.current) {
reconnectAttempts.current = 0;
}
const isDev =
!isElectron() &&
@@ -632,9 +675,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (reconnectAttempts.current > 0) {
attemptReconnection();
} else {
shouldNotReconnectRef.current = true;
if (onClose) {
onClose();
}
}
}
}, 10000);
}, 35000);
ws.send(
JSON.stringify({
@@ -659,9 +707,60 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const msg = JSON.parse(event.data);
if (msg.type === "data") {
if (typeof msg.data === "string") {
terminal.write(msg.data);
const syntaxHighlightingEnabled =
localStorage.getItem("terminalSyntaxHighlighting") === "true";
const outputData = syntaxHighlightingEnabled
? highlightTerminalOutput(msg.data)
: msg.data;
terminal.write(outputData);
const sudoPasswordPattern =
/(?:\[sudo\] password for \S+:|sudo: a password is required)/;
const passwordToFill =
hostConfig.terminalConfig?.sudoPassword || hostConfig.password;
if (
config.sudoPasswordAutoFill &&
sudoPasswordPattern.test(msg.data) &&
passwordToFill &&
!sudoPromptShownRef.current
) {
sudoPromptShownRef.current = true;
confirmWithToast(
t("terminal.sudoPasswordPopupTitle"),
async () => {
if (
webSocketRef.current &&
webSocketRef.current.readyState === WebSocket.OPEN
) {
webSocketRef.current.send(
JSON.stringify({
type: "input",
data: passwordToFill + "\n",
}),
);
}
setTimeout(() => {
sudoPromptShownRef.current = false;
}, 3000);
},
t("common.confirm"),
t("common.cancel"),
);
setTimeout(() => {
sudoPromptShownRef.current = false;
}, 15000);
}
} else {
terminal.write(String(msg.data));
const syntaxHighlightingEnabled =
localStorage.getItem("terminalSyntaxHighlighting") === "true";
const stringData = String(msg.data);
const outputData = syntaxHighlightingEnabled
? highlightTerminalOutput(stringData)
: stringData;
terminal.write(outputData);
}
} else if (msg.type === "error") {
const errorMessage = msg.message || t("terminal.unknownError");
@@ -739,7 +838,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
data: `export ${envVar.key}="${envVar.value}"\n`,
}),
);
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
}
@@ -758,7 +856,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
data: snippet.content + "\n",
}),
);
await new Promise((resolve) => setTimeout(resolve, 200));
}
} catch (err) {
console.warn("Failed to execute startup snippet:", err);
@@ -773,7 +870,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}),
);
}
}, 500);
}, 100);
} else if (msg.type === "disconnected") {
wasDisconnectedBySSH.current = true;
setIsConnected(false);
@@ -785,21 +882,50 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
onClose();
}
} else if (msg.type === "totp_required") {
totpAttemptsRef.current = 0;
setTotpRequired(true);
setTotpPrompt(msg.prompt || "Verification code:");
setTotpPrompt(msg.prompt || t("terminal.totpCodeLabel"));
setIsPasswordPrompt(false);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
}
totpTimeoutRef.current = setTimeout(() => {
setTotpRequired(false);
toast.error(t("terminal.totpTimeout"));
if (webSocketRef.current) {
webSocketRef.current.close();
}
}, 180000);
} else if (msg.type === "totp_retry") {
totpAttemptsRef.current++;
const attemptsRemaining =
msg.attempts_remaining || 3 - totpAttemptsRef.current;
toast.error(
`Invalid code. ${attemptsRemaining} ${attemptsRemaining === 1 ? "attempt" : "attempts"} remaining.`,
);
} else if (msg.type === "password_required") {
totpAttemptsRef.current = 0;
setTotpRequired(true);
setTotpPrompt(msg.prompt || "Password:");
setTotpPrompt(msg.prompt || t("common.password"));
setIsPasswordPrompt(true);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
}
totpTimeoutRef.current = setTimeout(() => {
setTotpRequired(false);
toast.error(t("terminal.passwordTimeout"));
if (webSocketRef.current) {
webSocketRef.current.close();
}
}, 180000);
} else if (msg.type === "keyboard_interactive_available") {
setKeyboardInteractiveDetected(true);
setIsConnecting(false);
@@ -821,13 +947,24 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}
});
const currentAttemptId = connectionAttemptIdRef.current;
ws.addEventListener("close", (event) => {
if (currentAttemptId !== connectionAttemptIdRef.current) {
return;
}
setIsConnected(false);
isConnectingRef.current = false;
if (terminal) {
terminal.clear();
}
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
}
if (event.code === 1008) {
console.error("WebSocket authentication failed:", event.reason);
setConnectionError("Authentication failed - please re-login");
@@ -847,7 +984,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (
!wasDisconnectedBySSH.current &&
!isUnmountingRef.current &&
!shouldNotReconnectRef.current
!shouldNotReconnectRef.current &&
!isConnectingRef.current
) {
wasDisconnectedBySSH.current = false;
attemptReconnection();
@@ -855,6 +993,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
});
ws.addEventListener("error", () => {
if (currentAttemptId !== connectionAttemptIdRef.current) {
return;
}
setIsConnected(false);
isConnectingRef.current = false;
setConnectionError(t("terminal.websocketError"));
@@ -862,7 +1004,17 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.clear();
}
setIsConnecting(false);
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
}
if (
!isUnmountingRef.current &&
!shouldNotReconnectRef.current &&
!isConnectingRef.current
) {
wasDisconnectedBySSH.current = false;
attemptReconnection();
}
@@ -946,8 +1098,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
setTimeout(() => {
terminal?.focus();
}, 50);
console.log(`[Autocomplete] ${currentCmd}${selectedCommand}`);
},
[terminal, updateCurrentCommand],
);
@@ -957,9 +1107,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if (!hostConfig.id) return;
try {
const { deleteCommandFromHistory } = await import(
"@/ui/main-axios.ts"
);
const { deleteCommandFromHistory } =
await import("@/ui/main-axios.ts");
await deleteCommandFromHistory(hostConfig.id, command);
setCommandHistory((prev) => {
@@ -971,8 +1120,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
autocompleteHistory.current = autocompleteHistory.current.filter(
(cmd) => cmd !== command,
);
console.log(`[Terminal] Command deleted from history: ${command}`);
} catch (error) {
console.error("Failed to delete command from history:", error);
}
@@ -992,8 +1139,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
...hostConfig.terminalConfig,
};
const themeColors =
TERMINAL_THEMES[config.theme]?.colors || TERMINAL_THEMES.termix.colors;
let themeColors;
if (config.theme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[config.theme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === config.fontFamily,
@@ -1061,6 +1216,16 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.open(xtermRef.current);
fitAddonRef.current?.fit();
if (terminal.cols < 10 || terminal.rows < 3) {
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
setIsFitted(true);
});
} else {
setIsFitted(true);
}
const element = xtermRef.current;
const handleContextMenu = async (e: MouseEvent) => {
if (!getUseRightClickCopyPaste()) return;
@@ -1088,22 +1253,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
if (
e.ctrlKey &&
e.key === "r" &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey
) {
e.preventDefault();
e.stopPropagation();
setShowHistoryDialog(true);
if (commandHistoryContext.openCommandHistory) {
commandHistoryContext.openCommandHistory();
}
return false;
}
if (
config.backspaceMode === "control-h" &&
e.key === "Backspace" &&
@@ -1158,22 +1307,19 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current || !isReady) return;
performFit();
if (isVisible && terminal?.cols > 0) {
performFit();
}
}, 50);
});
resizeObserver.observe(xtermRef.current);
setVisible(true);
return () => {
isUnmountingRef.current = true;
shouldNotReconnectRef.current = true;
isReconnectingRef.current = false;
setIsConnecting(false);
setVisible(false);
setIsReady(false);
isFittingRef.current = false;
resizeObserver.disconnect();
element?.removeEventListener("contextmenu", handleContextMenu);
@@ -1184,13 +1330,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
clearTimeout(reconnectTimeoutRef.current);
if (connectionTimeoutRef.current)
clearTimeout(connectionTimeoutRef.current);
if (totpTimeoutRef.current) clearTimeout(totpTimeoutRef.current);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close();
};
}, [xtermRef, terminal, hostConfig]);
}, [xtermRef, terminal, hostConfig, isDarkMode]);
useEffect(() => {
if (!terminal) return;
@@ -1294,7 +1441,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
e.stopPropagation();
const autocompleteEnabled =
localStorage.getItem("commandAutocomplete") !== "false";
localStorage.getItem("commandAutocomplete") === "true";
if (!autocompleteEnabled) {
if (webSocketRef.current?.readyState === 1) {
@@ -1378,81 +1525,43 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}, [terminal]);
useEffect(() => {
if (!terminal || !hostConfig || !visible) return;
if (!terminal || !hostConfig || !isVisible) return;
if (isConnected || isConnecting) return;
setIsConnecting(true);
const readyFonts =
(document as { fonts?: { ready?: Promise<unknown> } }).fonts
?.ready instanceof Promise
? (document as { fonts?: { ready?: Promise<unknown> } }).fonts.ready
: Promise.resolve();
readyFonts.then(() => {
if (terminal.cols < 10 || terminal.rows < 3) {
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
if (terminal.cols > 0 && terminal.rows > 0) {
setIsConnecting(true);
fitAddonRef.current?.fit();
scheduleNotify(terminal.cols, terminal.rows);
connectToHost(terminal.cols, terminal.rows);
}
hardRefresh();
setVisible(true);
setIsReady(true);
if (terminal && !splitScreen) {
terminal.focus();
}
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
setIsConnected(false);
setIsConnecting(false);
setConnectionError("Authentication required");
return;
}
const cols = terminal.cols;
const rows = terminal.rows;
connectToHost(cols, rows);
});
});
}, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
useEffect(() => {
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
return;
}
let rafId: number;
rafId = requestAnimationFrame(() => {
performFit();
});
return () => {
if (rafId) cancelAnimationFrame(rafId);
};
}, [isVisible, isReady, splitScreen, terminal]);
setIsConnecting(true);
fitAddonRef.current?.fit();
if (terminal.cols > 0 && terminal.rows > 0) {
scheduleNotify(terminal.cols, terminal.rows);
connectToHost(terminal.cols, terminal.rows);
}
}, [terminal, hostConfig, isVisible, isConnected, isConnecting]);
useEffect(() => {
if (
isFitted &&
isVisible &&
isReady &&
!isConnecting &&
terminal &&
!splitScreen
) {
const rafId = requestAnimationFrame(() => {
terminal.focus();
});
return () => cancelAnimationFrame(rafId);
}
}, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]);
if (!terminal || !fitAddonRef.current || !isVisible) return;
const fitTimeoutId = setTimeout(() => {
if (!isFittingRef.current && terminal.cols > 0 && terminal.rows > 0) {
performFit();
if (!splitScreen && !isConnecting) {
requestAnimationFrame(() => terminal.focus());
}
}
}, 0);
return () => clearTimeout(fitTimeoutId);
}, [terminal, isVisible, splitScreen, isConnecting]);
return (
<div className="h-full w-full relative" style={{ backgroundColor }}>
@@ -1460,8 +1569,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
ref={xtermRef}
className="h-full w-full"
style={{
visibility: isReady ? "visible" : "hidden",
pointerEvents: isReady ? "auto" : "none",
pointerEvents: isVisible ? "auto" : "none",
visibility: isConnecting || !isFitted ? "hidden" : "visible",
}}
onClick={() => {
if (terminal && !splitScreen) {
@@ -1548,20 +1657,32 @@ style.innerHTML = `
font-display: swap;
}
/* Light theme scrollbars */
.xterm .xterm-viewport::-webkit-scrollbar {
width: 8px;
background: transparent;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(180,180,180,0.7);
background: rgba(0,0,0,0.3);
border-radius: 4px;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(120,120,120,0.9);
background: rgba(0,0,0,0.5);
}
.xterm .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(180,180,180,0.7) transparent;
scrollbar-color: rgba(0,0,0,0.3) transparent;
}
/* Dark theme scrollbars */
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
}
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.5);
}
.dark .xterm .xterm-viewport {
scrollbar-color: rgba(255,255,255,0.3) transparent;
}
.xterm {

View File

@@ -1,5 +1,9 @@
import type { TerminalTheme } from "@/constants/terminal-themes";
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes";
import type { TerminalTheme } from "@/constants/terminal-themes.ts";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
} from "@/constants/terminal-themes.ts";
import { useTheme } from "@/components/theme-provider.tsx";
interface TerminalPreviewProps {
theme: string;
@@ -20,6 +24,17 @@ export function TerminalPreview({
letterSpacing = 0,
lineHeight = 1.2,
}: TerminalPreviewProps) {
const { theme: appTheme } = useTheme();
const resolvedTheme =
theme === "termix"
? appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
? "termixDark"
: "termixLight"
: theme;
return (
<div className="border border-input rounded-md overflow-hidden">
<div
@@ -31,33 +46,39 @@ export function TerminalPreview({
TERMINAL_FONTS[0].fallback,
letterSpacing: `${letterSpacing}px`,
lineHeight,
background: TERMINAL_THEMES[theme]?.colors.background || "#18181b",
color: TERMINAL_THEMES[theme]?.colors.foreground || "#f7f7f7",
background:
TERMINAL_THEMES[resolvedTheme]?.colors.background ||
"var(--bg-base)",
color:
TERMINAL_THEMES[resolvedTheme]?.colors.foreground ||
"var(--foreground)",
}}
>
<div>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.green }}>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
user@termix
</span>
<span>:</span>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.blue }}>~</span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
~
</span>
<span>$ ls -la</span>
</div>
<div>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.blue }}>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
drwxr-xr-x
</span>
<span> 5 user </span>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.cyan }}>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.cyan }}>
docs
</span>
</div>
<div>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.green }}>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
-rwxr-xr-x
</span>
<span> 1 user </span>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.green }}>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
script.sh
</span>
</div>
@@ -67,11 +88,13 @@ export function TerminalPreview({
<span>README.md</span>
</div>
<div>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.green }}>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
user@termix
</span>
<span>:</span>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.blue }}>~</span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
~
</span>
<span>$ </span>
<span
className="inline-block"
@@ -83,7 +106,8 @@ export function TerminalPreview({
: cursorStyle === "bar"
? `${fontSize}px`
: `${fontSize}px`,
background: TERMINAL_THEMES[theme]?.colors.cursor || "#f7f7f7",
background:
TERMINAL_THEMES[resolvedTheme]?.colors.cursor || "#f7f7f7",
animation: cursorBlink ? "blink 1s step-end infinite" : "none",
verticalAlign:
cursorStyle === "underline" ? "bottom" : "text-bottom",

View File

@@ -38,7 +38,7 @@ export function CommandAutocomplete({
return (
<div
ref={containerRef}
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
className="fixed z-[9999] bg-canvas border border-edge rounded-md shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
@@ -46,7 +46,7 @@ export function CommandAutocomplete({
}}
>
<div
className="overflow-y-auto"
className="overflow-y-auto thin-scrollbar"
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
>
{suggestions.map((suggestion, index) => (
@@ -55,8 +55,8 @@ export function CommandAutocomplete({
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
"hover:bg-dark-hover",
index === selectedIndex && "bg-gray-500/20 text-gray-400",
"hover:bg-hover",
index === selectedIndex && "bg-surface text-muted-foreground",
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {}}
@@ -65,7 +65,7 @@ export function CommandAutocomplete({
</div>
))}
</div>
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50 shrink-0">
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-edge bg-canvas/50 shrink-0">
Tab/Enter to complete to navigate Esc to close
</div>
</div>

View File

@@ -1,12 +1,13 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { TunnelViewer } from "@/ui/desktop/apps/tunnel/TunnelViewer.tsx";
import { TunnelViewer } from "@/ui/desktop/apps/features/tunnel/TunnelViewer.tsx";
import {
getSSHHosts,
getTunnelStatuses,
connectTunnel,
disconnectTunnel,
cancelTunnel,
logActivity,
} from "@/ui/main-axios.ts";
import type {
SSHHost,
@@ -27,6 +28,8 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
);
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const haveTunnelConnectionsChanged = (
a: TunnelConnection[] = [],
@@ -88,6 +91,25 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
}
}, [filterHostKey]);
const logTunnelActivity = async (host: SSHHost) => {
if (!host?.id || activityLoggedRef.current || activityLoggingRef.current) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName = host.name || `${host.username}@${host.ip}`;
await logActivity("tunnel", host.id, hostName);
} catch (err) {
console.warn("Failed to log tunnel activity:", err);
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
};
const fetchTunnelStatuses = useCallback(async () => {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
@@ -116,19 +138,23 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 5000);
const interval = setInterval(fetchTunnelStatuses, 1000);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
useEffect(() => {
if (visibleHosts.length > 0 && visibleHosts[0]) {
logTunnelActivity(visibleHosts[0]);
}
}, [visibleHosts.length > 0 ? visibleHosts[0]?.id : null]);
const handleTunnelAction = async (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${
tunnel.sourcePort
}_${tunnel.endpointHost}_${tunnel.endpointPort}`;
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
@@ -140,12 +166,10 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
if (!endpointHost) {
throw new Error(t("tunnels.endpointHostNotFound"));
}
const tunnelConfig = {
name: tunnelName,
sourceHostId: host.id,
tunnelIndex: tunnelIndex,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
@@ -159,32 +183,38 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointHost: tunnel.endpointHost,
endpointIP: endpointHost?.ip,
endpointSSHPort: endpointHost?.port,
endpointUsername: endpointHost?.username,
endpointPassword:
endpointHost.authType === "password"
endpointHost?.authType === "password"
? endpointHost.password
: undefined,
endpointAuthMethod: endpointHost.authType,
endpointAuthMethod: endpointHost?.authType,
endpointSSHKey:
endpointHost.authType === "key" ? endpointHost.key : undefined,
endpointHost?.authType === "key" ? endpointHost.key : undefined,
endpointKeyPassword:
endpointHost.authType === "key"
endpointHost?.authType === "key"
? endpointHost.keyPassword
: undefined,
endpointKeyType:
endpointHost.authType === "key" ? endpointHost.keyType : undefined,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
endpointHost?.authType === "key" ? endpointHost.keyType : undefined,
endpointCredentialId: endpointHost?.credentialId,
endpointUserId: endpointHost?.userId,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: host.pin,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
};
await connectTunnel(tunnelConfig);
} else if (action === "disconnect") {
await disconnectTunnel(tunnelName);
@@ -193,7 +223,15 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
}
await fetchTunnelStatuses();
} catch {
} catch (error) {
console.error("Tunnel action failed:", {
action,
tunnelName,
hostId: host.id,
tunnelIndex,
error: error instanceof Error ? error.message : String(error),
fullError: error,
});
} finally {
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
}

View File

@@ -0,0 +1,143 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Tunnel } from "@/ui/desktop/apps/features/tunnel/Tunnel.tsx";
import { useTranslation } from "react-i18next";
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
[key: string]: unknown;
}
interface TunnelManagerProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
export function TunnelManager({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: TunnelManagerProps): React.ReactElement {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setCurrentHostConfig(hostConfig);
}
}, [hostConfig?.id]);
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-1">
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 ? (
<div className="rounded-lg h-full overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-foreground-subtle text-lg">
{t("tunnel.noTunnelsConfigured")}
</p>
<p className="text-foreground-subtle text-sm mt-2">
{t("tunnel.configureTunnelsInHostSettings")}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import type {
export function TunnelObject({
host,
tunnelIndex,
tunnelStatuses,
tunnelActions,
onTunnelAction,
@@ -32,11 +33,9 @@ export function TunnelObject({
}: SSHTunnelObjectProps): React.ReactElement {
const { t } = useTranslation();
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${
tunnel.sourcePort
}_${tunnel.endpointHost}_${tunnel.endpointPort}`;
const getTunnelStatus = (idx: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[idx];
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
return tunnelStatuses[tunnelName];
};
@@ -104,7 +103,7 @@ export function TunnelObject({
default:
return {
icon: <WifiOff className="h-4 w-4" />,
text: statusValue,
text: t("tunnels.unknown"),
color: "text-muted-foreground",
bgColor: "bg-muted/30",
borderColor: "border-border",
@@ -118,12 +117,14 @@ export function TunnelObject({
<div className="space-y-3">
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
{(tunnelIndex !== undefined
? [tunnelIndex]
: host.tunnelConnections.map((_, idx) => idx)
).map((idx) => {
const tunnel = host.tunnelConnections[idx];
const status = getTunnelStatus(idx);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${
tunnel.sourcePort
}_${tunnel.endpointHost}_${tunnel.endpointPort}`;
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue =
status?.status?.toUpperCase() || "DISCONNECTED";
@@ -135,7 +136,7 @@ export function TunnelObject({
return (
<div
key={tunnelIndex}
key={idx}
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
>
<div className="flex items-start justify-between gap-2">
@@ -166,11 +167,7 @@ export function TunnelObject({
size="sm"
variant="outline"
onClick={() =>
onTunnelAction(
"disconnect",
host,
tunnelIndex,
)
onTunnelAction("disconnect", host, idx)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
@@ -183,7 +180,7 @@ export function TunnelObject({
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("cancel", host, tunnelIndex)
onTunnelAction("cancel", host, idx)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
@@ -195,7 +192,7 @@ export function TunnelObject({
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("connect", host, tunnelIndex)
onTunnelAction("connect", host, idx)
}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
@@ -243,7 +240,7 @@ export function TunnelObject({
>
{t("tunnels.discord")}
</a>{" "}
or create a{" "}
{t("tunnels.orCreate")}{" "}
<a
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"
@@ -348,17 +345,20 @@ export function TunnelObject({
{!compact && (
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4" />
{t("tunnels.tunnelConnections")} ({host.tunnelConnections.length})
{t("tunnels.tunnelConnections")} (
{tunnelIndex !== undefined ? 1 : host.tunnelConnections.length})
</h4>
)}
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
{(tunnelIndex !== undefined
? [tunnelIndex]
: host.tunnelConnections.map((_, idx) => idx)
).map((idx) => {
const tunnel = host.tunnelConnections[idx];
const status = getTunnelStatus(idx);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${
tunnel.sourcePort
}_${tunnel.endpointHost}_${tunnel.endpointPort}`;
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue =
status?.status?.toUpperCase() || "DISCONNECTED";
@@ -370,7 +370,7 @@ export function TunnelObject({
return (
<div
key={tunnelIndex}
key={idx}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
>
<div className="flex items-start justify-between gap-2">
@@ -401,11 +401,7 @@ export function TunnelObject({
size="sm"
variant="outline"
onClick={() =>
onTunnelAction(
"disconnect",
host,
tunnelIndex,
)
onTunnelAction("disconnect", host, idx)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
@@ -418,7 +414,7 @@ export function TunnelObject({
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("cancel", host, tunnelIndex)
onTunnelAction("cancel", host, idx)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
@@ -430,7 +426,7 @@ export function TunnelObject({
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("connect", host, tunnelIndex)
onTunnelAction("connect", host, idx)
}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
@@ -479,7 +475,7 @@ export function TunnelObject({
>
{t("tunnels.discord")}
</a>{" "}
or create a{" "}
{t("tunnels.orCreate")}{" "}
<a
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"

View File

@@ -43,25 +43,16 @@ export function TunnelViewer({
return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">
{t("tunnels.title")}
</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto pr-1">
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => (
<TunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={{
...activeHost,
tunnelConnections: [activeHost.tunnelConnections[idx]],
}}
key={`tunnel-${activeHost.id}-${idx}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={activeHost}
tunnelIndex={idx}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={(action) =>
onTunnelAction(action, activeHost, idx)
}
onTunnelAction={onTunnelAction}
compact
bare
/>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,542 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button.tsx";
import { Form } from "@/components/ui/form.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import React, { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
createCredential,
updateCredential,
getCredentials,
getCredentialDetails,
detectKeyType,
detectPublicKeyType,
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import { oneDark } from "@codemirror/theme-one-dark";
import { githubLight } from "@uiw/codemirror-theme-github";
import { useTheme } from "@/components/theme-provider.tsx";
import type {
Credential,
CredentialEditorProps,
CredentialData,
} from "../../../../../types";
import { CredentialGeneralTab } from "./tabs/CredentialGeneralTab";
import { CredentialAuthenticationTab } from "./tabs/CredentialAuthenticationTab";
export function CredentialEditor({
editingCredential,
onFormSubmit,
}: CredentialEditorProps) {
const { t } = useTranslation();
const { theme: appTheme } = useTheme();
const isDarkMode =
appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const editorTheme = isDarkMode ? oneDark : githubLight;
const [, setCredentials] = useState<Credential[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [, setLoading] = useState(true);
const [fullCredentialDetails, setFullCredentialDetails] =
useState<Credential | null>(null);
const [authTab, setAuthTab] = useState<"password" | "key">("password");
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
string | null
>(null);
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] =
useState(false);
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setFormError(null);
}, [activeTab]);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const credentialsData = await getCredentials();
setCredentials(credentialsData);
const uniqueFolders = [
...new Set(
credentialsData
.filter(
(credential) =>
credential.folder && credential.folder.trim() !== "",
)
.map((credential) => credential.folder!),
),
].sort() as string[];
setFolders(uniqueFolders);
} catch {
} finally {
setLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
const fetchCredentialDetails = async () => {
if (editingCredential) {
try {
const fullDetails = await getCredentialDetails(editingCredential.id);
setFullCredentialDetails(fullDetails);
} catch {
toast.error(t("credentials.failedToFetchCredentialDetails"));
}
} else {
setFullCredentialDetails(null);
}
};
fetchCredentialDetails();
}, [editingCredential, t]);
const formSchema = z
.object({
name: z.string().min(1),
description: z.string().optional(),
folder: z.string().optional(),
tags: z.array(z.string().min(1)).default([]),
authType: z.enum(["password", "key"]),
username: z.string().min(1),
password: z.string().optional(),
key: z.any().optional().nullable(),
publicKey: z.string().optional(),
keyPassword: z.string().optional(),
keyType: z
.enum([
"auto",
"ssh-rsa",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-dss",
"ssh-rsa-sha2-256",
"ssh-rsa-sha2-512",
])
.optional(),
})
.superRefine((data, ctx) => {
if (data.authType === "password") {
if (!data.password || data.password.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("credentials.passwordRequired"),
path: ["password"],
});
}
} else if (data.authType === "key") {
if (!data.key && !editingCredential) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("credentials.sshKeyRequired"),
path: ["key"],
});
}
}
});
type FormData = z.infer<typeof formSchema>;
const form = useForm<FormData>({
resolver: zodResolver(formSchema) as unknown as Parameters<
typeof useForm<FormData>
>[0]["resolver"],
defaultValues: {
name: "",
description: "",
folder: "",
tags: [],
authType: "password",
username: "",
password: "",
key: null,
publicKey: "",
keyPassword: "",
keyType: "auto",
},
});
useEffect(() => {
if (editingCredential && fullCredentialDetails) {
const defaultAuthType = fullCredentialDetails.authType;
setAuthTab(defaultAuthType);
setTimeout(() => {
const formData = {
name: fullCredentialDetails.name || "",
description: fullCredentialDetails.description || "",
folder: fullCredentialDetails.folder || "",
tags: fullCredentialDetails.tags || [],
authType: defaultAuthType as "password" | "key",
username: fullCredentialDetails.username || "",
password: "",
key: null,
publicKey: "",
keyPassword: "",
keyType: "auto" as const,
};
if (defaultAuthType === "password") {
formData.password = fullCredentialDetails.password || "";
} else if (defaultAuthType === "key") {
formData.key = fullCredentialDetails.key || "";
formData.publicKey = fullCredentialDetails.publicKey || "";
formData.keyPassword = fullCredentialDetails.keyPassword || "";
formData.keyType =
(fullCredentialDetails.keyType as string) || ("auto" as const);
}
form.reset(formData);
setTagInput("");
}, 100);
} else if (!editingCredential) {
setAuthTab("password");
form.reset({
name: "",
description: "",
folder: "",
tags: [],
authType: "password",
username: "",
password: "",
key: null,
publicKey: "",
keyPassword: "",
keyType: "auto",
});
setTagInput("");
}
}, [editingCredential?.id, fullCredentialDetails, form]);
useEffect(() => {
return () => {
if (keyDetectionTimeoutRef.current) {
clearTimeout(keyDetectionTimeoutRef.current);
}
if (publicKeyDetectionTimeoutRef.current) {
clearTimeout(publicKeyDetectionTimeoutRef.current);
}
};
}, []);
const handleKeyTypeDetection = async (
keyValue: string,
keyPassword?: string,
) => {
if (!keyValue || keyValue.trim() === "") {
setDetectedKeyType(null);
return;
}
setKeyDetectionLoading(true);
try {
const result = await detectKeyType(keyValue, keyPassword);
if (result.success) {
setDetectedKeyType(result.keyType);
} else {
setDetectedKeyType("invalid");
}
} catch (error) {
setDetectedKeyType("error");
console.error("Key type detection error:", error);
} finally {
setKeyDetectionLoading(false);
}
};
const debouncedKeyDetection = (keyValue: string, keyPassword?: string) => {
if (keyDetectionTimeoutRef.current) {
clearTimeout(keyDetectionTimeoutRef.current);
}
keyDetectionTimeoutRef.current = setTimeout(() => {
handleKeyTypeDetection(keyValue, keyPassword);
}, 1000);
};
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
if (!publicKeyValue || publicKeyValue.trim() === "") {
setDetectedPublicKeyType(null);
return;
}
setPublicKeyDetectionLoading(true);
try {
const result = await detectPublicKeyType(publicKeyValue);
if (result.success) {
setDetectedPublicKeyType(result.keyType);
} else {
setDetectedPublicKeyType("invalid");
console.warn("Public key detection failed:", result.error);
}
} catch (error) {
setDetectedPublicKeyType("error");
console.error("Public key type detection error:", error);
} finally {
setPublicKeyDetectionLoading(false);
}
};
const debouncedPublicKeyDetection = (publicKeyValue: string) => {
if (publicKeyDetectionTimeoutRef.current) {
clearTimeout(publicKeyDetectionTimeoutRef.current);
}
publicKeyDetectionTimeoutRef.current = setTimeout(() => {
handlePublicKeyTypeDetection(publicKeyValue);
}, 1000);
};
const getFriendlyKeyTypeName = (keyType: string): string => {
const keyTypeMap: Record<string, string> = {
"ssh-rsa": t("credentials.keyTypeRSA"),
"ssh-ed25519": t("credentials.keyTypeEd25519"),
"ecdsa-sha2-nistp256": t("credentials.keyTypeEcdsaP256"),
"ecdsa-sha2-nistp384": t("credentials.keyTypeEcdsaP384"),
"ecdsa-sha2-nistp521": t("credentials.keyTypeEcdsaP521"),
"ssh-dss": t("credentials.keyTypeDsa"),
"rsa-sha2-256": t("credentials.keyTypeRsaSha256"),
"rsa-sha2-512": t("credentials.keyTypeRsaSha512"),
invalid: t("credentials.invalidKey"),
error: t("credentials.detectionError"),
unknown: t("credentials.unknown"),
};
return keyTypeMap[keyType] || keyType;
};
const onSubmit = async (data: FormData) => {
try {
setFormError(null);
if (!data.name || data.name.trim() === "") {
data.name = data.username;
}
const submitData: CredentialData = {
name: data.name,
description: data.description,
folder: data.folder,
tags: data.tags,
authType: data.authType,
username: data.username,
keyType: data.keyType,
};
submitData.password = null;
submitData.key = null;
submitData.publicKey = null;
submitData.keyPassword = null;
submitData.keyType = null;
if (data.authType === "password") {
submitData.password = data.password;
} else if (data.authType === "key") {
submitData.key = data.key;
submitData.publicKey = data.publicKey;
submitData.keyPassword = data.keyPassword;
submitData.keyType = data.keyType;
}
if (editingCredential) {
await updateCredential(editingCredential.id, submitData);
toast.success(
t("credentials.credentialUpdatedSuccessfully", { name: data.name }),
);
} else {
await createCredential(submitData);
toast.success(
t("credentials.credentialAddedSuccessfully", { name: data.name }),
);
}
if (onFormSubmit) {
onFormSubmit();
}
window.dispatchEvent(new CustomEvent("credentials:changed"));
form.reset();
} catch (error) {
console.error("Credential save error:", error);
if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error(t("credentials.failedToSaveCredential"));
}
}
};
const handleFormError = () => {
const errors = form.formState.errors;
if (
errors.name ||
errors.username ||
errors.description ||
errors.folder ||
errors.tags
) {
setActiveTab("general");
} else if (
errors.password ||
errors.key ||
errors.publicKey ||
errors.keyPassword ||
errors.keyType
) {
setActiveTab("authentication");
}
};
const [tagInput, setTagInput] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(null);
const folderValue = form.watch("folder");
const filteredFolders = React.useMemo(() => {
if (!folderValue) return folders;
return folders.filter((f) =>
f.toLowerCase().includes(folderValue.toLowerCase()),
);
}, [folderValue, folders]);
const handleFolderClick = (folder: string) => {
form.setValue("folder", folder);
setFolderDropdownOpen(false);
};
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
folderDropdownRef.current &&
!folderDropdownRef.current.contains(event.target as Node) &&
folderInputRef.current &&
!folderInputRef.current.contains(event.target as Node)
) {
setFolderDropdownOpen(false);
}
}
if (folderDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [folderDropdownOpen]);
return (
<div
className="flex-1 flex flex-col h-full min-h-0 w-full"
key={editingCredential?.id || "new"}
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
className="flex flex-col flex-1 min-h-0 h-full"
>
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
{formError && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList className="bg-button border border-edge-medium">
<TabsTrigger
value="general"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("credentials.general")}
</TabsTrigger>
<TabsTrigger
value="authentication"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("credentials.authentication")}
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
<CredentialGeneralTab
form={form}
folders={folders}
tagInput={tagInput}
setTagInput={setTagInput}
folderDropdownOpen={folderDropdownOpen}
setFolderDropdownOpen={setFolderDropdownOpen}
folderInputRef={folderInputRef}
folderDropdownRef={folderDropdownRef}
filteredFolders={filteredFolders}
handleFolderClick={handleFolderClick}
/>
</TabsContent>
<TabsContent value="authentication">
<CredentialAuthenticationTab
form={form}
authTab={authTab}
setAuthTab={setAuthTab}
detectedKeyType={detectedKeyType}
setDetectedKeyType={setDetectedKeyType}
keyDetectionLoading={keyDetectionLoading}
setKeyDetectionLoading={setKeyDetectionLoading}
detectedPublicKeyType={detectedPublicKeyType}
setDetectedPublicKeyType={setDetectedPublicKeyType}
publicKeyDetectionLoading={publicKeyDetectionLoading}
setPublicKeyDetectionLoading={setPublicKeyDetectionLoading}
keyDetectionTimeoutRef={keyDetectionTimeoutRef}
publicKeyDetectionTimeoutRef={publicKeyDetectionTimeoutRef}
editorTheme={editorTheme}
debouncedKeyDetection={debouncedKeyDetection}
debouncedPublicKeyDetection={debouncedPublicKeyDetection}
getFriendlyKeyTypeName={getFriendlyKeyTypeName}
/>
</TabsContent>
</Tabs>
</ScrollArea>
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" />
<Button className="translate-y-2" type="submit" variant="outline">
{editingCredential
? t("credentials.updateCredential")
: t("credentials.addCredential")}
</Button>
</footer>
</form>
</Form>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input.tsx";
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
import { getCredentials } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { Credential } from "../../../../types";
import type { Credential } from "../../../../../types";
interface CredentialSelectorProps {
value?: number | null;
@@ -165,7 +165,7 @@ export function CredentialSelector({
/>
</div>
<div className="max-h-60 overflow-y-auto p-2">
<div className="max-h-60 overflow-y-auto thin-scrollbar p-2">
{loading ? (
<div className="p-3 text-center text-sm text-muted-foreground">
{t("common.loading")}

View File

@@ -1,22 +1,22 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button.tsx";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
} from "@/components/ui/card.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
} from "@/components/ui/sheet.tsx";
import {
Key,
User,
@@ -34,7 +34,7 @@ import {
CheckCircle,
FileText,
} from "lucide-react";
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios";
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type {
@@ -108,9 +108,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
const getAuthIcon = (authType: string) => {
return authType === "password" ? (
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<Key className="h-5 w-5 text-foreground-subtle" />
) : (
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
<Shield className="h-5 w-5 text-foreground-subtle" />
);
};
@@ -127,7 +127,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
<label className="text-sm font-medium text-foreground-secondary">
{label}
</label>
<div className="flex items-center space-x-2">
@@ -152,7 +152,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</div>
</div>
<div
className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? "" : "min-h-[2.5rem]"}`}
className={`p-3 rounded-md bg-surface ${isMultiline ? "" : "min-h-[2.5rem]"}`}
>
{isVisible ? (
<pre
@@ -161,7 +161,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
{value}
</pre>
) : (
<div className="text-sm text-zinc-500 dark:text-zinc-400">
<div className="text-sm text-foreground-subtle">
{"•".repeat(isMultiline ? 50 : 20)}
</div>
)}
@@ -175,7 +175,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
<Sheet open={true} onOpenChange={onClose}>
<SheetContent className="w-[600px] max-w-[50vw]">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground-subtle"></div>
</div>
</SheetContent>
</Sheet>
@@ -184,31 +184,28 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto thin-scrollbar">
<SheetHeader className="space-y-6 pb-8">
<SheetTitle className="flex items-center space-x-4">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
<div className="p-2 rounded-lg bg-surface">
{getAuthIcon(credentialDetails.authType)}
</div>
<div className="flex-1">
<div className="text-xl font-semibold">
{credentialDetails.name}
</div>
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
<div className="text-sm font-normal text-foreground-subtle mt-1">
{credentialDetails.description}
</div>
</div>
<div className="flex items-center space-x-2">
<Badge
variant="outline"
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
>
<Badge variant="outline" className="text-foreground-subtle">
{credentialDetails.authType}
</Badge>
{credentialDetails.keyType && (
<Badge
variant="secondary"
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
className="bg-surface text-foreground-secondary"
>
{credentialDetails.keyType}
</Badge>
@@ -218,7 +215,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</SheetHeader>
<div className="space-y-10">
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
<div className="flex space-x-2 p-2 bg-surface border border-border rounded-lg">
<Button
variant={activeTab === "overview" ? "default" : "ghost"}
size="sm"
@@ -250,7 +247,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
{activeTab === "overview" && (
<div className="grid gap-10 lg:grid-cols-2">
<Card className="border-zinc-200 dark:border-zinc-700">
<Card className="border-border">
<CardHeader className="pb-8">
<CardTitle className="text-lg font-semibold">
{t("credentials.basicInformation")}
@@ -258,14 +255,14 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</CardHeader>
<CardContent className="space-y-8">
<div className="flex items-center space-x-5">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div className="p-2 rounded-lg bg-surface">
<User className="h-4 w-4 text-foreground-subtle" />
</div>
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
<div className="text-sm text-foreground-subtle">
{t("common.username")}
</div>
<div className="font-medium text-zinc-800 dark:text-zinc-200">
<div className="font-medium text-foreground">
{credentialDetails.username}
</div>
</div>
@@ -273,9 +270,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
{credentialDetails.folder && (
<div className="flex items-center space-x-4">
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<Folder className="h-4 w-4 text-foreground-subtle" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
<div className="text-sm text-foreground-subtle">
{t("common.folder")}
</div>
<div className="font-medium">
@@ -287,9 +284,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
{credentialDetails.tags.length > 0 && (
<div className="flex items-start space-x-4">
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
<Hash className="h-4 w-4 text-foreground-subtle mt-1" />
<div className="flex-1">
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">
<div className="text-sm text-foreground-subtle mb-3">
{t("hosts.tags")}
</div>
<div className="flex flex-wrap gap-2">
@@ -310,9 +307,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
<Separator />
<div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<Calendar className="h-4 w-4 text-foreground-subtle" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
<div className="text-sm text-foreground-subtle">
{t("credentials.created")}
</div>
<div className="font-medium">
@@ -322,9 +319,9 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</div>
<div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<Calendar className="h-4 w-4 text-foreground-subtle" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
<div className="text-sm text-foreground-subtle">
{t("credentials.lastModified")}
</div>
<div className="font-medium">
@@ -342,20 +339,20 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
<div className="text-center p-6 bg-surface rounded-lg">
<div className="text-3xl font-bold text-foreground-subtle">
{credentialDetails.usageCount}
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">
<div className="text-sm text-foreground-subtle">
{t("credentials.timesUsed")}
</div>
</div>
{credentialDetails.lastUsed && (
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<div className="flex items-center space-x-4 p-4 bg-surface rounded-lg">
<Clock className="h-5 w-5 text-foreground-subtle" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
<div className="text-sm text-foreground-subtle">
{t("credentials.lastUsed")}
</div>
<div className="font-medium">
@@ -365,10 +362,10 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</div>
)}
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<div className="flex items-center space-x-4 p-4 bg-surface rounded-lg">
<Server className="h-5 w-5 text-foreground-subtle" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
<div className="text-sm text-foreground-subtle">
{t("credentials.connectedHosts")}
</div>
<div className="font-medium">{hostsUsing.length}</div>
@@ -383,7 +380,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<Shield className="h-5 w-5 text-foreground-subtle" />
<span>{t("credentials.securityDetails")}</span>
</CardTitle>
<CardDescription>
@@ -391,13 +388,13 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
<div className="flex items-center space-x-4 p-6 bg-surface rounded-lg">
<CheckCircle className="h-6 w-6 text-foreground-subtle" />
<div>
<div className="font-medium text-zinc-800 dark:text-zinc-200">
<div className="font-medium text-foreground">
{t("credentials.credentialSecured")}
</div>
<div className="text-sm text-zinc-700 dark:text-zinc-300">
<div className="text-sm text-foreground-secondary">
{t("credentials.credentialSecuredDescription")}
</div>
</div>
@@ -424,7 +421,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
<div className="grid gap-6 md:grid-cols-2">
<div>
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
<div className="text-sm font-medium text-foreground-secondary mb-3">
{t("credentials.keyType")}
</div>
<Badge variant="outline" className="text-sm">
@@ -450,13 +447,13 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
</div>
)}
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
<div className="flex items-start space-x-4 p-6 bg-surface rounded-lg">
<AlertTriangle className="h-5 w-5 text-foreground-subtle mt-0.5" />
<div className="text-sm">
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
<div className="font-medium text-foreground mb-2">
{t("credentials.securityReminder")}
</div>
<div className="text-zinc-700 dark:text-zinc-300">
<div className="text-foreground-secondary">
{t("credentials.securityReminderText")}
</div>
</div>
@@ -469,15 +466,15 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<Server className="h-5 w-5 text-foreground-subtle" />
<span>{t("credentials.hostsUsingCredential")}</span>
<Badge variant="secondary">{hostsUsing.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{hostsUsing.length === 0 ? (
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
<div className="text-center py-10 text-foreground-subtle">
<Server className="h-12 w-12 mx-auto mb-6 text-foreground-subtle" />
<p>{t("credentials.noHostsUsingCredential")}</p>
</div>
) : (
@@ -486,22 +483,22 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
{hostsUsing.map((host) => (
<div
key={host.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
className="flex items-center justify-between p-4 border rounded-lg hover:bg-surface"
>
<div className="flex items-center space-x-3">
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
<div className="p-2 bg-surface rounded">
<Server className="h-4 w-4 text-foreground-subtle" />
</div>
<div>
<div className="font-medium">
{host.name || `${host.ip}:${host.port}`}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
<div className="text-sm text-foreground-subtle">
{host.ip}:{host.port}
</div>
</div>
</div>
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
<div className="text-right text-sm text-foreground-subtle">
{formatDate(host.createdAt)}
</div>
</div>

View File

@@ -1,33 +1,33 @@
import React, { useState, useEffect, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import { Input } from "@/components/ui/input.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Sheet, SheetContent } from "@/components/ui/sheet";
} from "@/components/ui/accordion.tsx";
import { Sheet, SheetContent } from "@/components/ui/sheet.tsx";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
} from "@/components/ui/tooltip.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
} from "@/components/ui/popover.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
} from "@/components/ui/command.tsx";
import {
Search,
Key,
@@ -46,7 +46,7 @@ import {
User,
ChevronsUpDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils.ts";
import {
getCredentials,
deleteCredential,
@@ -54,15 +54,12 @@ import {
renameCredentialFolder,
deployCredentialToHost,
getSSHHosts,
} from "@/ui/main-axios";
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import CredentialViewer from "./CredentialViewer";
import type {
Credential,
CredentialsManagerProps,
} from "../../../../types/index.js";
import CredentialViewer from "./CredentialViewer.tsx";
import type { Credential, CredentialsManagerProps } from "../../../../../types";
export function CredentialsManager({
onEditCredential,
@@ -141,11 +138,11 @@ export function CredentialsManager({
const handleDeploy = (credential: Credential) => {
if (credential.authType !== "key") {
toast.error("Only SSH key-based credentials can be deployed");
toast.error(t("credentials.keyBasedOnlyForDeployment"));
return;
}
if (!credential.publicKey) {
toast.error("Public key is required for deployment");
toast.error(t("credentials.publicKeyRequiredForDeployment"));
return;
}
setDeployingCredential(credential);
@@ -156,7 +153,7 @@ export function CredentialsManager({
const performDeploy = async () => {
if (!deployingCredential || !selectedHostId) {
toast.error("Please select a target host");
toast.error(t("credentials.selectTargetHost"));
return;
}
@@ -173,11 +170,11 @@ export function CredentialsManager({
setDeployingCredential(null);
setSelectedHostId("");
} else {
toast.error(result.error || "Deployment failed");
toast.error(result.error || t("credentials.deploymentFailed"));
}
} catch (error) {
console.error("Deployment error:", error);
toast.error("Failed to deploy SSH key");
toast.error(t("credentials.failedToDeployKey"));
} finally {
setDeployLoading(false);
}
@@ -564,7 +561,7 @@ export function CredentialsManager({
}}
title={
folder !== t("credentials.uncategorized")
? "Click to rename folder"
? t("credentials.clickToRenameFolder")
: ""
}
>
@@ -579,7 +576,7 @@ export function CredentialsManager({
startFolderEdit(folder);
}}
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
title="Rename folder"
title={t("credentials.renameFolder")}
>
<Pencil className="h-3 w-3" />
</Button>
@@ -603,7 +600,7 @@ export function CredentialsManager({
handleDragStart(e, credential)
}
onDragEnd={handleDragEnd}
className={`bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group relative ${
className={`bg-field border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-hover-alt transition-all duration-200 p-3 group relative ${
draggedCredential?.id === credential.id
? "opacity-50 scale-95"
: ""
@@ -622,7 +619,8 @@ export function CredentialsManager({
{credential.username}
</p>
<p className="text-xs text-muted-foreground truncate">
ID: {credential.id}
{t("credentials.idLabel")}{" "}
{credential.id}
</p>
<p className="text-xs text-muted-foreground truncate">
{credential.authType === "password"
@@ -808,7 +806,7 @@ export function CredentialsManager({
)}
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto bg-dark-bg">
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto thin-scrollbar bg-canvas">
<div className="px-4 py-4">
<div className="space-y-3 pb-4">
<div className="flex items-center space-x-3">
@@ -867,7 +865,8 @@ export function CredentialsManager({
{t("credentials.keyType")}
</div>
<div className="text-sm font-medium">
{deployingCredential.keyType || "SSH Key"}
{deployingCredential.keyType ||
t("credentials.sshKey")}
</div>
</div>
</div>
@@ -917,7 +916,7 @@ export function CredentialsManager({
? t("credentials.noHostsAvailable")
: t("credentials.noHostsMatchSearch")}
</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{availableHosts.map((host) => (
<CommandItem
key={host.id}

View File

@@ -0,0 +1,514 @@
import {
FormField,
FormItem,
FormLabel,
FormControl,
} from "@/components/ui/form.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Controller } from "react-hook-form";
import CodeMirror from "@uiw/react-codemirror";
import { EditorView } from "@codemirror/view";
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
generateKeyPair,
generatePublicKeyFromPrivate,
} from "@/ui/main-axios.ts";
import type { CredentialAuthenticationTabProps } from "./shared/tab-types";
export function CredentialAuthenticationTab({
form,
authTab,
setAuthTab,
detectedKeyType,
detectedPublicKeyType,
keyDetectionLoading,
publicKeyDetectionLoading,
editorTheme,
debouncedKeyDetection,
debouncedPublicKeyDetection,
getFriendlyKeyTypeName,
}: CredentialAuthenticationTabProps) {
const { t } = useTranslation();
return (
<>
<FormLabel className="mb-2 font-bold">
{t("credentials.authentication")}
</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
const newAuthType = value as "password" | "key";
setAuthTab(newAuthType);
form.setValue("authType", newAuthType);
form.setValue("password", "");
form.setValue("key", null);
form.setValue("keyPassword", "");
form.setValue("keyType", "auto");
}}
className="flex-1 flex flex-col h-full min-h-0"
>
<TabsList className="bg-button border border-edge-medium">
<TabsTrigger
value="password"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("credentials.password")}
</TabsTrigger>
<TabsTrigger
value="key"
className="bg-button data-[state=active]:bg-elevated data-[state=active]:border data-[state=active]:border-edge-medium"
>
{t("credentials.key")}
</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("credentials.password")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
<div className="mt-2">
<div className="mb-3 p-3 border border-muted rounded-md">
<FormLabel className="mb-2 font-bold block">
{t("credentials.generateKeyPair")}
</FormLabel>
<div className="mb-2">
<div className="text-sm text-muted-foreground">
{t("credentials.generateKeyPairDescription")}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword = form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-ed25519",
undefined,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t("credentials.keyPairGeneratedSuccessfully", {
keyType: "Ed25519",
}),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate Ed25519 key pair:",
error,
);
toast.error(t("credentials.failedToGenerateKeyPair"));
}
}}
>
{t("credentials.generateEd25519")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword = form.watch("keyPassword");
const result = await generateKeyPair(
"ecdsa-sha2-nistp256",
undefined,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t("credentials.keyPairGeneratedSuccessfully", {
keyType: "ECDSA",
}),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error(
"Failed to generate ECDSA key pair:",
error,
);
toast.error(t("credentials.failedToGenerateKeyPair"));
}
}}
>
{t("credentials.generateECDSA")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
const currentKeyPassword = form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-rsa",
2048,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
debouncedKeyDetection(
result.privateKey,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t("credentials.keyPairGeneratedSuccessfully", {
keyType: "RSA",
}),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error("Failed to generate RSA key pair:", error);
toast.error(t("credentials.failedToGenerateKeyPair"));
}
}}
>
{t("credentials.generateRSA")}
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 items-start">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="mb-1">
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedKeyDetection(
fileContent,
form.watch("keyPassword"),
);
} catch (error) {
console.error(
"Failed to read uploaded file:",
error,
);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
</div>
<FormControl>
<CodeMirror
value={
typeof field.value === "string" ? field.value : ""
}
onChange={(value) => {
field.onChange(value);
debouncedKeyDetection(
value,
form.watch("keyPassword"),
);
}}
placeholder={t("placeholders.pastePrivateKey")}
theme={editorTheme}
className="border border-input rounded-md overflow-hidden"
minHeight="120px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
},
}),
]}
/>
</FormControl>
{detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedKeyType === "invalid" ||
detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
{t("credentials.sshPublicKey")}
</FormLabel>
<div className="mb-1 flex gap-2">
<div className="relative inline-block flex-1">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedPublicKeyDetection(fileContent);
} catch (error) {
console.error(
"Failed to read uploaded public key file:",
error,
);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPublicKeyFile")}
</span>
</Button>
</div>
<Button
type="button"
variant="outline"
className="flex-shrink-0"
onClick={async () => {
const privateKey = form.watch("key");
if (
!privateKey ||
typeof privateKey !== "string" ||
!privateKey.trim()
) {
toast.error(
t("credentials.privateKeyRequiredForGeneration"),
);
return;
}
try {
const keyPassword = form.watch("keyPassword");
const result = await generatePublicKeyFromPrivate(
privateKey,
keyPassword,
);
if (result.success && result.publicKey) {
field.onChange(result.publicKey);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
t("credentials.publicKeyGeneratedSuccessfully"),
);
} else {
toast.error(
result.error ||
t("credentials.failedToGeneratePublicKey"),
);
}
} catch (error) {
console.error(
"Failed to generate public key:",
error,
);
toast.error(
t("credentials.failedToGeneratePublicKey"),
);
}
}}
>
{t("credentials.generatePublicKey")}
</Button>
</div>
<FormControl>
<CodeMirror
value={field.value || ""}
onChange={(value) => {
field.onChange(value);
debouncedPublicKeyDetection(value);
}}
placeholder={t("placeholders.pastePublicKey")}
theme={editorTheme}
className="border border-input rounded-md overflow-hidden"
minHeight="120px"
basicSetup={{
lineNumbers: true,
foldGutter: false,
dropCursor: false,
allowMultipleSelections: false,
highlightSelectionMatches: false,
searchKeymap: false,
scrollPastEnd: false,
}}
extensions={[
EditorView.theme({
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor:
"var(--scrollbar-thumb) var(--scrollbar-track)",
},
}),
]}
/>
</FormControl>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedPublicKeyType === "invalid" ||
detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedPublicKeyType)}
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-8 gap-3 mt-3">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>{t("credentials.keyPassword")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</TabsContent>
</Tabs>
</>
);
}

View File

@@ -0,0 +1,193 @@
import {
FormField,
FormItem,
FormLabel,
FormControl,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import React from "react";
import { useTranslation } from "react-i18next";
import type { CredentialGeneralTabProps } from "./shared/tab-types";
export function CredentialGeneralTab({
form,
folders,
tagInput,
setTagInput,
folderDropdownOpen,
setFolderDropdownOpen,
folderInputRef,
folderDropdownRef,
filteredFolders,
handleFolderClick,
}: CredentialGeneralTabProps) {
const { t } = useTranslation();
return (
<>
<FormLabel className="mb-2 font-bold">
{t("credentials.basicInformation")}
</FormLabel>
<div className="grid grid-cols-12 gap-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("credentials.credentialName")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.credentialName")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("credentials.username")}</FormLabel>
<FormControl>
<Input placeholder={t("placeholders.username")} {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-2 mt-4 font-bold">
{t("credentials.organization")}
</FormLabel>
<div className="grid grid-cols-26 gap-3">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="col-span-10">
<FormLabel>{t("credentials.description")}</FormLabel>
<FormControl>
<Input placeholder={t("placeholders.description")} {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="folder"
render={({ field }) => (
<FormItem className="col-span-10 relative">
<FormLabel>{t("credentials.folder")}</FormLabel>
<FormControl>
<Input
ref={folderInputRef}
placeholder={t("placeholders.folder")}
className="min-h-[40px]"
autoComplete="off"
value={field.value}
onFocus={() => setFolderDropdownOpen(true)}
onChange={(e) => {
field.onChange(e);
setFolderDropdownOpen(true);
}}
/>
</FormControl>
{folderDropdownOpen && filteredFolders.length > 0 && (
<div
ref={folderDropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{filteredFolders.map((folder) => (
<Button
key={folder}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => handleFolderClick(folder)}
>
{folder}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>{t("credentials.tags")}</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-field focus-within:ring-2 ring-ring min-h-[40px]">
{(field.value || []).map((tag: string, idx: number) => (
<span
key={`${tag}-${idx}`}
className="flex items-center bg-surface text-foreground rounded-full px-2 py-0.5 text-xs"
>
{tag}
<button
type="button"
className="ml-1 text-foreground-subtle hover:text-red-500 focus:outline-none"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const newTags = (field.value || []).filter(
(_: string, i: number) => i !== idx,
);
field.onChange(newTags);
}}
>
×
</button>
</span>
))}
<input
type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent text-foreground placeholder:text-muted-foreground p-0 h-6 text-sm"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === " " && tagInput.trim() !== "") {
e.preventDefault();
const currentTags = field.value || [];
if (!currentTags.includes(tagInput.trim())) {
field.onChange([...currentTags, tagInput.trim()]);
}
setTagInput("");
} else if (e.key === "Enter" && tagInput.trim() !== "") {
e.preventDefault();
const currentTags = field.value || [];
if (!currentTags.includes(tagInput.trim())) {
field.onChange([...currentTags, tagInput.trim()]);
}
setTagInput("");
} else if (
e.key === "Backspace" &&
tagInput === "" &&
(field.value || []).length > 0
) {
const currentTags = field.value || [];
field.onChange(currentTags.slice(0, -1));
}
}}
placeholder={t("credentials.addTagsSpaceToAdd")}
/>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,35 @@
import type { UseFormReturn } from "react-hook-form";
import type React from "react";
export interface CredentialGeneralTabProps {
form: UseFormReturn<FormData>;
folders: string[];
tagInput: string;
setTagInput: (value: string) => void;
folderDropdownOpen: boolean;
setFolderDropdownOpen: (value: boolean) => void;
folderInputRef: React.RefObject<HTMLInputElement>;
folderDropdownRef: React.RefObject<HTMLDivElement>;
filteredFolders: string[];
handleFolderClick: (folder: string) => void;
}
export interface CredentialAuthenticationTabProps {
form: UseFormReturn<FormData>;
authTab: "password" | "key";
setAuthTab: (value: "password" | "key") => void;
detectedKeyType: string | null;
setDetectedKeyType: (value: string | null) => void;
keyDetectionLoading: boolean;
setKeyDetectionLoading: (value: boolean) => void;
detectedPublicKeyType: string | null;
setDetectedPublicKeyType: (value: string | null) => void;
publicKeyDetectionLoading: boolean;
setPublicKeyDetectionLoading: (value: boolean) => void;
keyDetectionTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
publicKeyDetectionTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
editorTheme: unknown;
debouncedKeyDetection: (keyValue: string, keyPassword?: string) => void;
debouncedPublicKeyDetection: (publicKeyValue: string) => void;
getFriendlyKeyTypeName: (keyType: string) => string;
}

View File

@@ -6,9 +6,9 @@ import {
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
} from "@/components/ui/dialog.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { useTranslation } from "react-i18next";
import {
Folder,
@@ -94,7 +94,7 @@ export function FolderEditDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Folder className="w-5 h-5" />
@@ -119,7 +119,7 @@ export function FolderEditDialog({
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
selectedColor === color.value
? "border-white shadow-lg scale-105"
: "border-dark-border"
: "border-edge"
}`}
style={{ backgroundColor: color.value }}
onClick={() => setSelectedColor(color.value)}
@@ -141,7 +141,7 @@ export function FolderEditDialog({
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
selectedIcon === value
? "border-primary bg-primary/10"
: "border-dark-border bg-dark-bg-darker"
: "border-edge bg-elevated"
}`}
onClick={() => setSelectedIcon(value)}
title={label}
@@ -156,7 +156,7 @@ export function FolderEditDialog({
<Label className="text-base font-semibold text-foreground">
{t("hosts.preview")}
</Label>
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
<div className="flex items-center gap-3 p-4 rounded-md bg-elevated border border-edge">
{(() => {
const IconComponent =
AVAILABLE_ICONS.find((i) => i.value === selectedIcon)?.Icon ||

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { HostManagerViewer } from "@/ui/desktop/apps/host-manager/HostManagerViewer.tsx";
import { HostManagerViewer } from "@/ui/desktop/apps/host-manager/hosts/HostManagerViewer.tsx";
import {
Tabs,
TabsContent,
@@ -7,9 +7,9 @@ import {
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { HostManagerEditor } from "@/ui/desktop/apps/host-manager/HostManagerEditor.tsx";
import { CredentialsManager } from "@/ui/desktop/apps/credentials/CredentialsManager.tsx";
import { CredentialEditor } from "@/ui/desktop/apps/credentials/CredentialEditor.tsx";
import { HostManagerEditor } from "@/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx";
import { CredentialsManager } from "@/ui/desktop/apps/host-manager/credentials/CredentialsManager.tsx";
import { CredentialEditor } from "@/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { useTranslation } from "react-i18next";
import type { SSHHost, HostManagerProps } from "../../../types/index";
@@ -18,8 +18,11 @@ export function HostManager({
isTopbarOpen,
initialTab = "host_viewer",
hostConfig,
_updateTimestamp,
rightSidebarOpen = false,
rightSidebarWidth = 400,
currentTabId,
updateTab,
}: HostManagerProps): React.ReactElement {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(initialTab);
@@ -27,6 +30,8 @@ export function HostManager({
hostConfig || null,
);
useEffect(() => {}, [editingHost]);
const [editingCredential, setEditingCredential] = useState<{
id: number;
name?: string;
@@ -37,15 +42,44 @@ export function HostManager({
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (initialTab) {
setActiveTab(initialTab);
if (_updateTimestamp !== undefined) {
if (initialTab && initialTab !== activeTab) {
setActiveTab(initialTab);
}
if (hostConfig && hostConfig.id !== editingHost?.id) {
setEditingHost(hostConfig);
lastProcessedHostIdRef.current = hostConfig.id;
} else if (
!hostConfig &&
editingHost &&
editingHost.id !== lastProcessedHostIdRef.current
) {
setEditingHost(null);
}
if (initialTab !== "add_credential" && editingCredential) {
setEditingCredential(null);
}
} else {
if (initialTab) {
setActiveTab(initialTab);
}
if (hostConfig) {
setEditingHost(hostConfig);
lastProcessedHostIdRef.current = hostConfig.id;
}
}
}, [initialTab]);
}, [_updateTimestamp, initialTab, hostConfig?.id]);
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
setActiveTab("add_host");
lastProcessedHostIdRef.current = host.id;
if (updateTab && currentTabId !== undefined) {
updateTab(currentTabId, { initialTab: "add_host" });
}
};
const handleFormSubmit = () => {
@@ -64,6 +98,10 @@ export function HostManager({
}) => {
setEditingCredential(credential);
setActiveTab("add_credential");
if (updateTab && currentTabId !== undefined) {
updateTab(currentTabId, { initialTab: "add_credential" });
}
};
const handleCredentialFormSubmit = () => {
@@ -79,6 +117,10 @@ export function HostManager({
setEditingCredential(null);
}
setActiveTab(value);
if (updateTab && currentTabId !== undefined) {
updateTab(currentTabId, { initialTab: value });
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
@@ -89,7 +131,7 @@ export function HostManager({
<div>
<div className="w-full">
<div
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
className="bg-canvas text-foreground p-4 pt-0 rounded-lg border-2 border-edge flex flex-col min-h-0 overflow-hidden"
style={{
marginLeft: leftMarginPx,
marginRight: rightSidebarOpen
@@ -107,22 +149,34 @@ export function HostManager({
onValueChange={handleTabChange}
className="flex-1 flex flex-col h-full min-h-0"
>
<TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5">
<TabsTrigger value="host_viewer">
<TabsList className="bg-elevated border-2 border-edge mt-1.5">
<TabsTrigger
value="host_viewer"
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
{t("hosts.hostViewer")}
</TabsTrigger>
<TabsTrigger value="add_host">
<TabsTrigger
value="add_host"
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
{editingHost
? editingHost.id
? t("hosts.editHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</TabsTrigger>
<div className="h-6 w-px bg-dark-border mx-1"></div>
<TabsTrigger value="credentials">
<div className="h-6 w-px bg-border-base mx-1"></div>
<TabsTrigger
value="credentials"
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
{t("credentials.credentialsViewer")}
</TabsTrigger>
<TabsTrigger value="add_credential">
<TabsTrigger
value="add_credential"
className="bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
>
{editingCredential
? t("credentials.editCredential")
: t("credentials.addCredential")}
@@ -152,7 +206,7 @@ export function HostManager({
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" />
<div className="flex flex-col h-full min-h-0 overflow-auto">
<div className="flex flex-col h-full min-h-0 overflow-auto thin-scrollbar">
<CredentialsManager onEditCredential={handleEditCredential} />
</div>
</TabsContent>

File diff suppressed because it is too large Load Diff

View File

@@ -48,8 +48,6 @@ import {
Pencil,
FolderMinus,
Copy,
Activity,
Clock,
Palette,
Trash,
Cloud,
@@ -61,15 +59,19 @@ import {
HardDrive,
Globe,
FolderOpen,
Share2,
Users,
ArrowDownUp,
Container,
} from "lucide-react";
import type {
SSHHost,
SSHFolder,
SSHManagerHostViewerProps,
} from "../../../../types/index.js";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { FolderEditDialog } from "./components/FolderEditDialog";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
} from "../../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
import { FolderEditDialog } from "@/ui/desktop/apps/host-manager/dialogs/FolderEditDialog.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const { t } = useTranslation();
@@ -361,7 +363,11 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
),
);
const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], {
const exportFormat = {
hosts: [cleanExportData],
};
const blob = new Blob([JSON.stringify(exportFormat, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
@@ -374,7 +380,9 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
URL.revokeObjectURL(url);
toast.success(
`Exported host configuration for ${host.name || host.username}@${host.ip}`,
t("hosts.exportedHostConfig", {
name: host.name || `${host.username}@${host.ip}`,
}),
);
} catch {
toast.error(t("hosts.failedToExportHost"));
@@ -524,6 +532,184 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}
};
const getSampleData = () => ({
hosts: [
{
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
notes: "Main production web server running Nginx",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
defaultPath: "/var/www",
},
{
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\\nYour SSH private key content here\\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
notes: "PostgreSQL production database",
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
enableDocker: false,
tunnelConnections: [
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
},
],
statsConfig: {
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime"],
statusCheckEnabled: true,
statusCheckInterval: 30,
metricsEnabled: true,
metricsInterval: 30,
},
},
{
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
overrideCredentialUsername: false,
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
notes: "Development environment for testing",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: true,
defaultPath: "/home/developer",
},
{
name: "Jump Host Server",
ip: "10.0.0.50",
port: 22,
username: "sysadmin",
authType: "password",
password: "secure_password",
folder: "Infrastructure",
tags: ["bastion", "jump-host"],
notes: "Jump host for accessing internal network",
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
enableDocker: false,
jumpHosts: [
{
hostId: 1,
},
],
quickActions: [
{
name: "System Update",
snippetId: 5,
},
],
},
{
name: "Server with SOCKS5 Proxy",
ip: "10.10.10.100",
port: 22,
username: "proxyuser",
authType: "password",
password: "secure_password",
folder: "Proxied Hosts",
tags: ["proxy", "socks5"],
notes: "Accessible through SOCKS5 proxy",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
useSocks5: true,
socks5Host: "proxy.example.com",
socks5Port: 1080,
socks5Username: "proxyauth",
socks5Password: "proxypass",
},
{
name: "Customized Terminal Server",
ip: "192.168.1.150",
port: 22,
username: "devops",
authType: "password",
password: "terminal_password",
folder: t("interface.developmentFolder"),
tags: ["custom", "terminal"],
notes: "Server with custom terminal configuration",
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
enableDocker: false,
defaultPath: "/opt/apps",
terminalConfig: {
cursorBlink: true,
cursorStyle: "bar",
fontSize: 16,
fontFamily: "jetbrainsMono",
letterSpacing: 0.5,
lineHeight: 1.2,
theme: "monokai",
scrollback: 50000,
bellStyle: "visual",
rightClickSelectsWord: true,
fastScrollModifier: "ctrl",
fastScrollSensitivity: 7,
minimumContrastRatio: 4,
backspaceMode: "normal",
agentForwarding: true,
environmentVariables: [
{
key: "NODE_ENV",
value: "development",
},
],
autoMosh: false,
sudoPasswordAutoFill: true,
sudoPassword: "sudo_password_here",
},
},
],
});
const handleDownloadSample = () => {
const sampleData = getSampleData();
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sample-ssh-hosts.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleJsonImport = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
@@ -576,47 +762,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}
};
const getMonitoringStatus = (host: SSHHost) => {
try {
const statsConfig = host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
const formatInterval = (seconds: number): string => {
if (seconds >= 60) {
const minutes = Math.round(seconds / 60);
return `${minutes}m`;
}
return `${seconds}s`;
};
const statusEnabled = statsConfig.statusCheckEnabled !== false;
const metricsEnabled = statsConfig.metricsEnabled !== false;
const statusInterval = statusEnabled
? formatInterval(statsConfig.statusCheckInterval || 30)
: null;
const metricsInterval = metricsEnabled
? formatInterval(statsConfig.metricsInterval || 30)
: null;
return {
statusEnabled,
metricsEnabled,
statusInterval,
metricsInterval,
bothDisabled: !statusEnabled && !metricsEnabled,
};
} catch {
return {
statusEnabled: true,
metricsEnabled: true,
statusInterval: "30s",
metricsInterval: "30s",
bothDisabled: false,
};
}
};
const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts;
@@ -739,84 +884,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => {
const sampleData = {
hosts: [
{
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/var/www",
},
{
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
tunnelConnections: [
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
},
],
},
{
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/home/developer",
},
],
};
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sample-ssh-hosts.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
>
<Button variant="outline" size="sm" onClick={handleDownloadSample}>
{t("hosts.downloadSample")}
</Button>
@@ -900,84 +968,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => {
const sampleData = {
hosts: [
{
name: t("interface.webServerProduction"),
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: t("interface.productionFolder"),
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/var/www",
},
{
name: t("interface.databaseServer"),
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: t("interface.productionFolder"),
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
tunnelConnections: [
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: t("interface.webServerProduction"),
maxRetries: 3,
retryInterval: 10,
autoStart: true,
},
],
},
{
name: t("interface.developmentServer"),
ip: "192.168.1.102",
port: 2222,
username: "developer",
authType: "credential",
credentialId: 1,
folder: t("interface.developmentFolder"),
tags: ["dev", "testing"],
pin: false,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/home/developer",
},
],
};
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "sample-ssh-hosts.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
>
<Button variant="outline" size="sm" onClick={handleDownloadSample}>
{t("hosts.downloadSample")}
</Button>
@@ -1106,7 +1097,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}}
title={
folder !== t("hosts.uncategorized")
? "Click to rename folder"
? t("hosts.clickToRenameFolder")
: ""
}
>
@@ -1121,7 +1112,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
startFolderEdit(folder);
}}
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
title="Rename folder"
title={t("hosts.renameFolder")}
>
<Pencil className="h-3 w-3" />
</Button>
@@ -1187,7 +1178,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
draggable
onDragStart={(e) => handleDragStart(e, host)}
onDragEnd={handleDragEnd}
className={`bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group relative ${
className={`bg-field border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-hover-alt transition-all duration-200 p-3 group relative ${
draggedHost?.id === host.id
? "opacity-50 scale-95"
: ""
@@ -1230,6 +1221,14 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{host.name ||
`${host.username}@${host.ip}`}
</h3>
{(host as any).isShared && (
<Badge
variant="outline"
className="text-xs px-1 py-0 text-violet-500 border-violet-500/50"
>
{t("rbac.shared")}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port}
@@ -1242,29 +1241,33 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</p>
</div>
<div className="flex gap-1 flex-shrink-0 ml-1">
{host.folder && host.folder !== "" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleRemoveFromFolder(host);
}}
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
disabled={operationLoading}
>
<FolderMinus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
Remove from folder "{host.folder}"
</p>
</TooltipContent>
</Tooltip>
)}
{!(host as any).isShared &&
host.folder &&
host.folder !== "" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleRemoveFromFolder(host);
}}
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
disabled={operationLoading}
>
<FolderMinus className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("hosts.removeFromFolder", {
folder: host.folder,
})}
</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1280,67 +1283,75 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit host</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(
host.id,
host.name ||
`${host.username}@${host.ip}`,
);
}}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete host</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleExport(host);
}}
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10"
>
<Upload className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Export host</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleClone(host);
}}
className="h-5 w-5 p-0 text-emerald-500 hover:text-emerald-700 hover:bg-emerald-500/10"
>
<Copy className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Clone host</p>
<p>{t("hosts.editHostTooltip")}</p>
</TooltipContent>
</Tooltip>
{!(host as any).isShared && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(
host.id,
host.name ||
`${host.username}@${host.ip}`,
);
}}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("hosts.deleteHostTooltip")}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleExport(host);
}}
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10"
>
<Upload className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("hosts.exportHostTooltip")}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleClone(host);
}}
className="h-5 w-5 p-0 text-emerald-500 hover:text-emerald-700 hover:bg-emerald-500/10"
>
<Copy className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("hosts.cloneHostTooltip")}</p>
</TooltipContent>
</Tooltip>
</>
)}
</div>
</div>
@@ -1404,48 +1415,15 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
{t("hosts.fileManagerBadge")}
</Badge>
)}
{(() => {
const monitoringStatus =
getMonitoringStatus(host);
if (monitoringStatus.bothDisabled) {
return (
<Badge
variant="outline"
className="text-xs px-1 py-0 text-muted-foreground"
>
<Activity className="h-2 w-2 mr-0.5" />
{t("hosts.monitoringDisabledBadge")}
</Badge>
);
}
return (
<>
{monitoringStatus.statusEnabled && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Activity className="h-2 w-2 mr-0.5" />
{t("hosts.statusMonitoring")}:{" "}
{monitoringStatus.statusInterval}
</Badge>
)}
{monitoringStatus.metricsEnabled && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Clock className="h-2 w-2 mr-0.5" />
{t("hosts.metricsMonitoring")}:{" "}
{monitoringStatus.metricsInterval}
</Badge>
)}
</>
);
})()}
{host.enableDocker && (
<Badge
variant="outline"
className="text-xs px-1 py-0"
>
<Container className="h-2 w-2 mr-0.5" />
{t("hosts.docker")}
</Badge>
)}
</div>
</div>
@@ -1473,7 +1451,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Terminal</p>
<p>{t("hosts.openTerminal")}</p>
</TooltipContent>
</Tooltip>
)}
@@ -1504,6 +1482,60 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
</TooltipContent>
</Tooltip>
)}
{host.enableTunnel && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "tunnel",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-orange-500/10 hover:border-orange-500/50 flex-1"
>
<ArrowDownUp className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Tunnels</p>
</TooltipContent>
</Tooltip>
)}
{host.enableDocker && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "docker",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-cyan-500/10 hover:border-cyan-500/50 flex-1"
>
<Container className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("hosts.openDocker")}</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1515,7 +1547,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "server",
type: "server_stats",
title,
hostConfig: host,
});
@@ -1535,10 +1567,10 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<TooltipContent>
<div className="text-center">
<p className="font-medium">
Click to edit host
{t("hosts.clickToEditHost")}
</p>
<p className="text-xs text-muted-foreground">
Drag to move between folders
{t("hosts.dragToMoveBetweenFolders")}
</p>
</div>
</TooltipContent>

View File

@@ -0,0 +1,38 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import type { HostDockerTabProps } from "./shared/tab-types";
import { Button } from "@/components/ui/button.tsx";
export function HostDockerTab({ form, t }: HostDockerTabProps) {
return (
<div className="space-y-2">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() => window.open("https://docs.termix.site/docker", "_blank")}
>
{t("common.documentation")}
</Button>
<FormField
control={form.control}
name="enableDocker"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableDocker")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>{t("hosts.enableDockerDesc")}</FormDescription>
</FormItem>
)}
/>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import type { HostFileManagerTabProps } from "./shared/tab-types";
export function HostFileManagerTab({ form, t }: HostFileManagerTabProps) {
return (
<div>
<FormField
control={form.control}
name="enableFileManager"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableFileManager")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>
{t("hosts.enableFileManagerDesc")}
</FormDescription>
</FormItem>
)}
/>
{form.watch("enableFileManager") && (
<div className="mt-4">
<FormField
control={form.control}
name="defaultPath"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.defaultPath")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.homePath")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>{t("hosts.defaultPathDesc")}</FormDescription>
</FormItem>
)}
/>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,571 @@
import React from "react";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getRoles,
getUserList,
getUserInfo,
shareHost,
getHostAccess,
revokeHostAccess,
getSSHHostById,
type Role,
type AccessRecord,
} from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import {
Plus,
Check,
ChevronsUpDown,
AlertCircle,
Trash2,
Users,
Shield,
Clock,
UserCircle,
} from "lucide-react";
import type { SSHHost } from "@/types";
import type { HostSharingTabProps } from "./shared/tab-types";
interface User {
id: string;
username: string;
is_admin: boolean;
}
interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
}
export function HostSharingTab({
hostId,
isNewHost,
}: SharingTabContentProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [shareType, setShareType] = React.useState<"user" | "role">("user");
const [selectedUserId, setSelectedUserId] = React.useState<string>("");
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
null,
);
const [permissionLevel, setPermissionLevel] = React.useState("view");
const [expiresInHours, setExpiresInHours] = React.useState<string>("");
const [roles, setRoles] = React.useState<Role[]>([]);
const [users, setUsers] = React.useState<User[]>([]);
const [accessList, setAccessList] = React.useState<AccessRecord[]>([]);
const [loading, setLoading] = React.useState(false);
const [currentUserId, setCurrentUserId] = React.useState<string>("");
const [hostData, setHostData] = React.useState<SSHHost | null>(null);
const [userComboOpen, setUserComboOpen] = React.useState(false);
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
const loadRoles = React.useCallback(async () => {
try {
const response = await getRoles();
setRoles(response.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
setRoles([]);
}
}, []);
const loadUsers = React.useCallback(async () => {
try {
const response = await getUserList();
const mappedUsers = (response.users || []).map((user) => ({
id: user.id,
username: user.username,
is_admin: user.is_admin,
}));
setUsers(mappedUsers);
} catch (error) {
console.error("Failed to load users:", error);
setUsers([]);
}
}, []);
const loadAccessList = React.useCallback(async () => {
if (!hostId) return;
setLoading(true);
try {
const response = await getHostAccess(hostId);
setAccessList(response.accessList || []);
} catch (error) {
console.error("Failed to load access list:", error);
setAccessList([]);
} finally {
setLoading(false);
}
}, [hostId]);
const loadHostData = React.useCallback(async () => {
if (!hostId) return;
try {
const host = await getSSHHostById(hostId);
setHostData(host);
} catch (error) {
console.error("Failed to load host data:", error);
setHostData(null);
}
}, [hostId]);
React.useEffect(() => {
loadRoles();
loadUsers();
if (!isNewHost) {
loadAccessList();
loadHostData();
}
}, [loadRoles, loadUsers, loadAccessList, loadHostData, isNewHost]);
React.useEffect(() => {
const fetchCurrentUser = async () => {
try {
const userInfo = await getUserInfo();
setCurrentUserId(userInfo.userId);
} catch (error) {
console.error("Failed to load current user:", error);
}
};
fetchCurrentUser();
}, []);
const handleShare = async () => {
if (!hostId) {
toast.error(t("rbac.saveHostFirst"));
return;
}
if (shareType === "user" && !selectedUserId) {
toast.error(t("rbac.selectUser"));
return;
}
if (shareType === "role" && !selectedRoleId) {
toast.error(t("rbac.selectRole"));
return;
}
if (shareType === "user" && selectedUserId === currentUserId) {
toast.error(t("rbac.cannotShareWithSelf"));
return;
}
try {
await shareHost(hostId, {
targetType: shareType,
targetUserId: shareType === "user" ? selectedUserId : undefined,
targetRoleId: shareType === "role" ? selectedRoleId : undefined,
permissionLevel,
durationHours: expiresInHours
? parseInt(expiresInHours, 10)
: undefined,
});
toast.success(t("rbac.sharedSuccessfully"));
setSelectedUserId("");
setSelectedRoleId(null);
setExpiresInHours("");
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToShare"));
}
};
const handleRevoke = async (accessId: number) => {
if (!hostId) return;
const confirmed = await confirmWithToast({
title: t("rbac.confirmRevokeAccess"),
description: t("rbac.confirmRevokeAccessDescription"),
confirmText: t("common.revoke"),
cancelText: t("common.cancel"),
});
if (!confirmed) return;
try {
await revokeHostAccess(hostId, accessId);
toast.success(t("rbac.accessRevokedSuccessfully"));
loadAccessList();
} catch (error) {
toast.error(t("rbac.failedToRevokeAccess"));
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
return new Date(dateString).toLocaleString();
};
const isExpired = (expiresAt: string | null) => {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
};
const availableUsers = React.useMemo(() => {
return users.filter((user) => user.id !== currentUserId);
}, [users, currentUserId]);
const selectedUser = availableUsers.find((u) => u.id === selectedUserId);
const selectedRole = roles.find((r) => r.id === selectedRoleId);
if (isNewHost) {
return (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.saveHostFirst")}</AlertTitle>
<AlertDescription>
{t("rbac.saveHostFirstDescription")}
</AlertDescription>
</Alert>
);
}
return (
<div className="space-y-6">
{!hostData?.credentialId && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t("rbac.credentialRequired")}</AlertTitle>
<AlertDescription>
{t("rbac.credentialRequiredDescription")}
</AlertDescription>
</Alert>
)}
{hostData?.credentialId && (
<>
<div className="space-y-4 border rounded-lg p-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Plus className="h-5 w-5" />
{t("rbac.shareHost")}
</h3>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/rbac", "_blank")
}
>
{t("common.documentation")}
</Button>
<Tabs
value={shareType}
onValueChange={(v) => setShareType(v as "user" | "role")}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="user" className="flex items-center gap-2">
<UserCircle className="h-4 w-4" />
{t("rbac.shareWithUser")}
</TabsTrigger>
<TabsTrigger value="role" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("rbac.shareWithRole")}
</TabsTrigger>
</TabsList>
<TabsContent value="user" className="space-y-4">
<div className="space-y-2">
<label htmlFor="user-select">{t("rbac.selectUser")}</label>
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={userComboOpen}
className="w-full justify-between"
>
{selectedUser
? `${selectedUser.username}${selectedUser.is_admin ? " (Admin)" : ""}`
: t("rbac.selectUserPlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchUsers")} />
<CommandEmpty>{t("rbac.noUserFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{availableUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.username} ${user.id}`}
onSelect={() => {
setSelectedUserId(user.id);
setUserComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedUserId === user.id
? "opacity-100"
: "opacity-0",
)}
/>
{user.username}
{user.is_admin ? " (Admin)" : ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
<TabsContent value="role" className="space-y-4">
<div className="space-y-2">
<label htmlFor="role-select">{t("rbac.selectRole")}</label>
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={roleComboOpen}
className="w-full justify-between"
>
{selectedRole
? `${t(selectedRole.displayName)}${selectedRole.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
: t("rbac.selectRolePlaceholder")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("rbac.searchRoles")} />
<CommandEmpty>{t("rbac.noRoleFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{roles.map((role) => (
<CommandItem
key={role.id}
value={`${role.displayName} ${role.name} ${role.id}`}
onSelect={() => {
setSelectedRoleId(role.id);
setRoleComboOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedRoleId === role.id
? "opacity-100"
: "opacity-0",
)}
/>
{t(role.displayName)}
{role.isSystem
? ` (${t("rbac.systemRole")})`
: ""}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</TabsContent>
</Tabs>
<div className="space-y-2">
<label>{t("rbac.permissionLevel")}</label>
<div className="text-sm text-muted-foreground">
{t("rbac.view")} - {t("rbac.viewDesc")}
</div>
</div>
<div className="space-y-2">
<label htmlFor="expires-in">{t("rbac.durationHours")}</label>
<Input
id="expires-in"
type="number"
value={expiresInHours}
onChange={(e) => {
const value = e.target.value;
if (value === "" || /^\d+$/.test(value)) {
setExpiresInHours(value);
}
}}
placeholder={t("rbac.neverExpires")}
min="1"
/>
</div>
<Button
type="button"
onClick={handleShare}
className="w-full"
disabled={!hostData?.credentialId}
>
<Plus className="h-4 w-4 mr-2" />
{t("rbac.share")}
</Button>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Users className="h-5 w-5" />
{t("rbac.accessList")}
</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("rbac.type")}</TableHead>
<TableHead>{t("rbac.target")}</TableHead>
<TableHead>{t("rbac.permissionLevel")}</TableHead>
<TableHead>{t("rbac.grantedBy")}</TableHead>
<TableHead>{t("rbac.expires")}</TableHead>
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("common.loading")}
</TableCell>
</TableRow>
) : accessList.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-muted-foreground"
>
{t("rbac.noAccessRecords")}
</TableCell>
</TableRow>
) : (
accessList.map((access) => (
<TableRow
key={access.id}
className={
isExpired(access.expiresAt) ? "opacity-50" : ""
}
>
<TableCell>
{access.targetType === "user" ? (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<UserCircle className="h-3 w-3" />
{t("rbac.user")}
</Badge>
) : (
<Badge
variant="outline"
className="flex items-center gap-1 w-fit"
>
<Shield className="h-3 w-3" />
{t("rbac.role")}
</Badge>
)}
</TableCell>
<TableCell>
{access.targetType === "user"
? access.username
: t(access.roleDisplayName || access.roleName || "")}
</TableCell>
<TableCell>
<Badge variant="secondary">
{access.permissionLevel}
</Badge>
</TableCell>
<TableCell>{access.grantedByUsername}</TableCell>
<TableCell>
{access.expiresAt ? (
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
<span
className={
isExpired(access.expiresAt)
? "text-red-500"
: ""
}
>
{formatDate(access.expiresAt)}
{isExpired(access.expiresAt) && (
<span className="ml-2">
({t("rbac.expired")})
</span>
)}
</span>
</div>
) : (
t("rbac.never")
)}
</TableCell>
<TableCell className="text-right">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRevoke(access.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,340 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import { Plus, X } from "lucide-react";
import type { HostStatisticsTabProps } from "./shared/tab-types";
import { QuickActionItem } from "./shared/QuickActionItem";
export function HostStatisticsTab({
form,
statusIntervalUnit,
setStatusIntervalUnit,
metricsIntervalUnit,
setMetricsIntervalUnit,
snippets,
t,
}: HostStatisticsTabProps) {
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="space-y-3">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/server-stats", "_blank")
}
>
{t("common.documentation")}
</Button>
<FormField
control={form.control}
name="statsConfig.statusCheckEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.statusCheckEnabled")}</FormLabel>
<FormDescription>
{t("hosts.statusCheckEnabledDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("statsConfig.statusCheckEnabled") && (
<FormField
control={form.control}
name="statsConfig.statusCheckInterval"
render={({ field }) => {
const displayValue =
statusIntervalUnit === "minutes"
? Math.round((field.value || 30) / 60)
: field.value || 30;
const handleIntervalChange = (value: string) => {
const numValue = parseInt(value) || 0;
const seconds =
statusIntervalUnit === "minutes" ? numValue * 60 : numValue;
field.onChange(seconds);
};
return (
<FormItem>
<FormLabel>{t("hosts.statusCheckInterval")}</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input
type="number"
value={displayValue}
onChange={(e) => handleIntervalChange(e.target.value)}
className="flex-1"
/>
</FormControl>
<Select
value={statusIntervalUnit}
onValueChange={(value: "seconds" | "minutes") => {
setStatusIntervalUnit(value);
const currentSeconds = field.value || 30;
if (value === "minutes") {
const minutes = Math.round(currentSeconds / 60);
field.onChange(minutes * 60);
}
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seconds">
{t("hosts.intervalSeconds")}
</SelectItem>
<SelectItem value="minutes">
{t("hosts.intervalMinutes")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FormDescription>
{t("hosts.statusCheckIntervalDesc")}
</FormDescription>
</FormItem>
);
}}
/>
)}
</div>
<div className="space-y-3">
<FormField
control={form.control}
name="statsConfig.metricsEnabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.metricsEnabled")}</FormLabel>
<FormDescription>
{t("hosts.metricsEnabledDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("statsConfig.metricsEnabled") && (
<FormField
control={form.control}
name="statsConfig.metricsInterval"
render={({ field }) => {
const displayValue =
metricsIntervalUnit === "minutes"
? Math.round((field.value || 30) / 60)
: field.value || 30;
const handleIntervalChange = (value: string) => {
const numValue = parseInt(value) || 0;
const seconds =
metricsIntervalUnit === "minutes"
? numValue * 60
: numValue;
field.onChange(seconds);
};
return (
<FormItem>
<FormLabel>{t("hosts.metricsInterval")}</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input
type="number"
value={displayValue}
onChange={(e) => handleIntervalChange(e.target.value)}
className="flex-1"
/>
</FormControl>
<Select
value={metricsIntervalUnit}
onValueChange={(value: "seconds" | "minutes") => {
setMetricsIntervalUnit(value);
const currentSeconds = field.value || 30;
if (value === "minutes") {
const minutes = Math.round(currentSeconds / 60);
field.onChange(minutes * 60);
}
}}
>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="seconds">
{t("hosts.intervalSeconds")}
</SelectItem>
<SelectItem value="minutes">
{t("hosts.intervalMinutes")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FormDescription>
{t("hosts.metricsIntervalDesc")}
</FormDescription>
</FormItem>
);
}}
/>
)}
</div>
</div>
{form.watch("statsConfig.metricsEnabled") && (
<>
<FormField
control={form.control}
name="statsConfig.enabledWidgets"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enabledWidgets")}</FormLabel>
<FormDescription>
{t("hosts.enabledWidgetsDesc")}
</FormDescription>
<div className="space-y-3 mt-3">
{(
[
"cpu",
"memory",
"disk",
"network",
"uptime",
"processes",
"system",
"login_stats",
] as const
).map((widget) => (
<div key={widget} className="flex items-center space-x-2">
<Checkbox
checked={field.value?.includes(widget)}
onCheckedChange={(checked) => {
const currentWidgets = field.value || [];
if (checked) {
field.onChange([...currentWidgets, widget]);
} else {
field.onChange(
currentWidgets.filter((w) => w !== widget),
);
}
}}
/>
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{widget === "cpu" && t("serverStats.cpuUsage")}
{widget === "memory" && t("serverStats.memoryUsage")}
{widget === "disk" && t("serverStats.diskUsage")}
{widget === "network" &&
t("serverStats.networkInterfaces")}
{widget === "uptime" && t("serverStats.uptime")}
{widget === "processes" && t("serverStats.processes")}
{widget === "system" && t("serverStats.systemInfo")}
{widget === "login_stats" &&
t("serverStats.loginStats")}
</label>
</div>
))}
</div>
</FormItem>
)}
/>
</>
)}
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t("hosts.quickActions")}</h3>
<Alert>
<AlertDescription>
{t("hosts.quickActionsDescription")}
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="quickActions"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.quickActionsList")}</FormLabel>
<FormControl>
<div className="space-y-3">
{field.value.map((quickAction, index) => (
<QuickActionItem
key={index}
quickAction={quickAction}
index={index}
snippets={snippets}
onUpdate={(name, snippetId) => {
const newQuickActions = [...field.value];
newQuickActions[index] = {
name,
snippetId,
};
field.onChange(newQuickActions);
}}
onRemove={() => {
const newQuickActions = field.value.filter(
(_, i) => i !== index,
);
field.onChange(newQuickActions);
}}
t={t}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
field.onChange([
...field.value,
{ name: "", snippetId: 0 },
]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addQuickAction")}
</Button>
</div>
</FormControl>
<FormDescription>{t("hosts.quickActionsOrder")}</FormDescription>
</FormItem>
)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,767 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import { Slider } from "@/components/ui/slider.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
CURSOR_STYLES,
BELL_STYLES,
FAST_SCROLL_MODIFIERS,
} from "@/constants/terminal-themes.ts";
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
import type { HostTerminalTabProps } from "./shared/tab-types";
import React from "react";
export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
return (
<div className="space-y-1">
<FormField
control={form.control}
name="enableTerminal"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableTerminal")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>{t("hosts.enableTerminalDesc")}</FormDescription>
</FormItem>
)}
/>
<h1 className="text-xl font-semibold mt-7">
{t("hosts.terminalCustomization")}
</h1>
<Accordion
type="multiple"
className="w-full"
defaultValue={["appearance", "behavior", "advanced"]}
>
<AccordionItem value="appearance">
<AccordionTrigger>{t("hosts.appearance")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("hosts.themePreview")}
</label>
<TerminalPreview
theme={form.watch("terminalConfig.theme")}
fontSize={form.watch("terminalConfig.fontSize")}
fontFamily={form.watch("terminalConfig.fontFamily")}
cursorStyle={form.watch("terminalConfig.cursorStyle")}
cursorBlink={form.watch("terminalConfig.cursorBlink")}
letterSpacing={form.watch("terminalConfig.letterSpacing")}
lineHeight={form.watch("terminalConfig.lineHeight")}
/>
</div>
<FormField
control={form.control}
name="terminalConfig.theme"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.theme")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectTheme")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
<SelectItem key={key} value={key}>
{theme.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseColorTheme")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontFamily"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fontFamily")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectFont")} />
</SelectTrigger>
</FormControl>
<SelectContent>
{TERMINAL_FONTS.map((font) => (
<SelectItem key={font.value} value={font.value}>
{font.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>{t("hosts.selectFontDesc")}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fontSizeValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={8}
max={24}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>{t("hosts.adjustFontSize")}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.letterSpacing"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.letterSpacingValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={-2}
max={10}
step={0.5}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.adjustLetterSpacing")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.lineHeight"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.lineHeightValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
max={2}
step={0.1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.adjustLineHeight")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.cursorStyle"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.cursorStyle")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectCursorStyle")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="block">
{t("hosts.cursorStyleBlock")}
</SelectItem>
<SelectItem value="underline">
{t("hosts.cursorStyleUnderline")}
</SelectItem>
<SelectItem value="bar">
{t("hosts.cursorStyleBar")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.chooseCursorAppearance")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.cursorBlink"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.cursorBlink")}</FormLabel>
<FormDescription>
{t("hosts.enableCursorBlink")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="behavior">
<AccordionTrigger>{t("hosts.behavior")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={form.control}
name="terminalConfig.scrollback"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.scrollbackBufferValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1000}
max={100000}
step={1000}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.scrollbackBufferDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.bellStyle"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.bellStyle")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectBellStyle")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">
{t("hosts.bellStyleNone")}
</SelectItem>
<SelectItem value="sound">
{t("hosts.bellStyleSound")}
</SelectItem>
<SelectItem value="visual">
{t("hosts.bellStyleVisual")}
</SelectItem>
<SelectItem value="both">
{t("hosts.bellStyleBoth")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>{t("hosts.bellStyleDesc")}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.rightClickSelectsWord"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.rightClickSelectsWord")}</FormLabel>
<FormDescription>
{t("hosts.rightClickSelectsWordDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fastScrollModifier"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fastScrollModifier")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectModifier")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="alt">
{t("hosts.modifierAlt")}
</SelectItem>
<SelectItem value="ctrl">
{t("hosts.modifierCtrl")}
</SelectItem>
<SelectItem value="shift">
{t("hosts.modifierShift")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.fastScrollModifierDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.fastScrollSensitivity"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fastScrollSensitivityValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
max={10}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.fastScrollSensitivityDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.minimumContrastRatio"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.minimumContrastRatioValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
max={21}
step={1}
value={[field.value]}
onValueChange={([value]) => field.onChange(value)}
/>
</FormControl>
<FormDescription>
{t("hosts.minimumContrastRatioDesc")}
</FormDescription>
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
<AccordionItem value="advanced">
<AccordionTrigger>{t("hosts.advanced")}</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<FormField
control={form.control}
name="terminalConfig.agentForwarding"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.sshAgentForwarding")}</FormLabel>
<FormDescription>
{t("hosts.sshAgentForwardingDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.backspaceMode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.backspaceMode")}</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t("hosts.selectBackspaceMode")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="normal">
{t("hosts.backspaceModeNormal")}
</SelectItem>
<SelectItem value="control-h">
{t("hosts.backspaceModeControlH")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{t("hosts.backspaceModeDesc")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.startupSnippetId"
render={({ field }) => {
const [open, setOpen] = React.useState(false);
const selectedSnippet = snippets.find(
(s) => s.id === field.value,
);
return (
<FormItem>
<FormLabel>{t("hosts.startupSnippet")}</FormLabel>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedSnippet
? selectedSnippet.name
: t("hosts.selectSnippet")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{
width: "var(--radix-popover-trigger-width)",
}}
>
<Command>
<CommandInput
placeholder={t("hosts.searchSnippets")}
/>
<CommandEmpty>
{t("hosts.noSnippetFound")}
</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
<CommandItem
value="none"
onSelect={() => {
field.onChange(null);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
!field.value ? "opacity-100" : "opacity-0",
)}
/>
{t("hosts.snippetNone")}
</CommandItem>
{snippets.map((snippet) => (
<CommandItem
key={snippet.id}
value={`${snippet.name} ${snippet.content} ${snippet.id}`}
onSelect={() => {
field.onChange(snippet.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === snippet.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{snippet.name}
</span>
<span className="text-xs text-muted-foreground truncate max-w-[350px]">
{snippet.content}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
{t("hosts.executeSnippetOnConnect")}
</FormDescription>
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="terminalConfig.autoMosh"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.autoMosh")}</FormLabel>
<FormDescription>{t("hosts.autoMoshDesc")}</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("terminalConfig.autoMosh") && (
<FormField
control={form.control}
name="terminalConfig.moshCommand"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.moshCommand")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.moshCommand")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>
{t("hosts.moshCommandDesc")}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="terminalConfig.sudoPasswordAutoFill"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.sudoPasswordAutoFill")}</FormLabel>
<FormDescription>
{t("hosts.sudoPasswordAutoFillDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{form.watch("terminalConfig.sudoPasswordAutoFill") && (
<FormField
control={form.control}
name="terminalConfig.sudoPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.sudoPassword")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.sudoPassword")}
{...field}
/>
</FormControl>
<FormDescription>
{t("hosts.sudoPasswordDesc")}
</FormDescription>
</FormItem>
)}
/>
)}
<div className="space-y-2">
<label className="text-sm font-medium">
{t("hosts.environmentVariables")}
</label>
<FormDescription>
{t("hosts.environmentVariablesDesc")}
</FormDescription>
{form
.watch("terminalConfig.environmentVariables")
?.map((_, index) => (
<div key={index} className="flex gap-2">
<FormField
control={form.control}
name={`terminalConfig.environmentVariables.${index}.key`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder={t("hosts.variableName")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`terminalConfig.environmentVariables.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input
placeholder={t("hosts.variableValue")}
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const current = form.getValues(
"terminalConfig.environmentVariables",
);
form.setValue(
"terminalConfig.environmentVariables",
current.filter((_, i) => i !== index),
);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const current =
form.getValues("terminalConfig.environmentVariables") || [];
form.setValue("terminalConfig.environmentVariables", [
...current,
{ key: "", value: "" },
]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addVariable")}
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}

View File

@@ -0,0 +1,361 @@
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Switch } from "@/components/ui/switch.tsx";
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
import { Button } from "@/components/ui/button.tsx";
import type { HostTunnelTabProps } from "./shared/tab-types";
export function HostTunnelTab({
form,
sshConfigDropdownOpen,
setSshConfigDropdownOpen,
sshConfigInputRefs,
sshConfigDropdownRefs,
getFilteredSshConfigs,
handleSshConfigClick,
t,
}: HostTunnelTabProps) {
return (
<div>
<FormField
control={form.control}
name="enableTunnel"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.enableTunnel")}</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormDescription>{t("hosts.enableTunnelDesc")}</FormDescription>
</FormItem>
)}
/>
{form.watch("enableTunnel") && (
<>
<Alert className="mt-4">
<AlertDescription>
<strong>{t("hosts.sshpassRequired")}</strong>
<div>
{t("hosts.sshpassRequiredDesc")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo apt install sshpass
</code>{" "}
{t("hosts.debianUbuntuEquivalent")}
</div>
<div className="mt-2">
<strong>{t("hosts.otherInstallMethods")}</strong>
<div>
{t("hosts.centosRhelFedora")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo yum install sshpass
</code>{" "}
{t("hosts.or")}{" "}
<code className="bg-muted px-1 rounded inline">
sudo dnf install sshpass
</code>
</div>
<div>
{t("hosts.macos")}{" "}
<code className="bg-muted px-1 rounded inline">
brew install hudochenkov/sshpass/sshpass
</code>
</div>
<div> {t("hosts.windows")}</div>
</div>
</AlertDescription>
</Alert>
<Alert className="mt-4">
<AlertDescription>
<strong>{t("hosts.sshServerConfigRequired")}</strong>
<div>{t("hosts.sshServerConfigDesc")}</div>
<div>
{" "}
<code className="bg-muted px-1 rounded inline">
GatewayPorts yes
</code>{" "}
{t("hosts.gatewayPortsYes")}
</div>
<div>
{" "}
<code className="bg-muted px-1 rounded inline">
AllowTcpForwarding yes
</code>{" "}
{t("hosts.allowTcpForwardingYes")}
</div>
<div>
{" "}
<code className="bg-muted px-1 rounded inline">
PermitRootLogin yes
</code>{" "}
{t("hosts.permitRootLoginYes")}
</div>
<div className="mt-2">{t("hosts.editSshConfig")}</div>
</AlertDescription>
</Alert>
<div className="mt-3 flex justify-between">
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/tunnels", "_blank")
}
>
{t("common.documentation")}
</Button>
</div>
<FormField
control={form.control}
name="tunnelConnections"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("hosts.tunnelConnections")}</FormLabel>
<FormControl>
<div className="space-y-4">
{field.value.map((connection, index) => (
<div
key={index}
className="p-4 border rounded-lg bg-muted/50"
>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold">
{t("hosts.connection")} {index + 1}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const newConnections = field.value.filter(
(_, i) => i !== index,
);
field.onChange(newConnections);
}}
>
{t("hosts.remove")}
</Button>
</div>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name={`tunnelConnections.${index}.sourcePort`}
render={({ field: sourcePortField }) => (
<FormItem className="col-span-4">
<FormLabel>
{t("hosts.sourcePort")}
{t("hosts.sourcePortDesc")}
</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.defaultPort")}
{...sourcePortField}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.endpointPort`}
render={({ field: endpointPortField }) => (
<FormItem className="col-span-4">
<FormLabel>{t("hosts.endpointPort")}</FormLabel>
<FormControl>
<Input
placeholder={t(
"placeholders.defaultEndpointPort",
)}
{...endpointPortField}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.endpointHost`}
render={({ field: endpointHostField }) => (
<FormItem className="col-span-4 relative">
<FormLabel>
{t("hosts.endpointSshConfig")}
</FormLabel>
<FormControl>
<Input
ref={(el) => {
sshConfigInputRefs.current[index] = el;
}}
placeholder={t("placeholders.sshConfig")}
className="min-h-[40px]"
autoComplete="off"
value={endpointHostField.value}
onFocus={() =>
setSshConfigDropdownOpen((prev) => ({
...prev,
[index]: true,
}))
}
onChange={(e) => {
endpointHostField.onChange(e);
setSshConfigDropdownOpen((prev) => ({
...prev,
[index]: true,
}));
}}
onBlur={(e) => {
endpointHostField.onChange(
e.target.value.trim(),
);
endpointHostField.onBlur();
}}
/>
</FormControl>
{sshConfigDropdownOpen[index] &&
getFilteredSshConfigs(index).length > 0 && (
<div
ref={(el) => {
sshConfigDropdownRefs.current[index] =
el;
}}
className="absolute top-full left-0 z-50 mt-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{getFilteredSshConfigs(index).map(
(config) => (
<Button
key={config}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
onClick={() =>
handleSshConfigClick(
config,
index,
)
}
>
{config}
</Button>
),
)}
</div>
</div>
)}
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground mt-2">
{t("hosts.tunnelForwardDescription", {
sourcePort:
form.watch(
`tunnelConnections.${index}.sourcePort`,
) || "22",
endpointPort:
form.watch(
`tunnelConnections.${index}.endpointPort`,
) || "224",
})}
</p>
<div className="grid grid-cols-12 gap-4 mt-4">
<FormField
control={form.control}
name={`tunnelConnections.${index}.maxRetries`}
render={({ field: maxRetriesField }) => (
<FormItem className="col-span-4">
<FormLabel>{t("hosts.maxRetries")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.maxRetries")}
{...maxRetriesField}
/>
</FormControl>
<FormDescription>
{t("hosts.maxRetriesDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.retryInterval`}
render={({ field: retryIntervalField }) => (
<FormItem className="col-span-4">
<FormLabel>
{t("hosts.retryInterval")}
</FormLabel>
<FormControl>
<Input
placeholder={t(
"placeholders.retryInterval",
)}
{...retryIntervalField}
/>
</FormControl>
<FormDescription>
{t("hosts.retryIntervalDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.autoStart`}
render={({ field }) => (
<FormItem className="col-span-4">
<FormLabel>
{t("hosts.autoStartContainer")}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t("hosts.autoStartDesc")}
</FormDescription>
</FormItem>
)}
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => {
field.onChange([
...field.value,
{
sourcePort: 22,
endpointPort: 224,
endpointHost: "",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
},
]);
}}
>
{t("hosts.addConnection")}
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
import React from "react";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import { Check, ChevronsUpDown, X } from "lucide-react";
import type { JumpHostItemProps } from "./tab-types";
export function JumpHostItem({
jumpHost,
index,
hosts,
editingHost,
onUpdate,
onRemove,
t,
}: JumpHostItemProps) {
const [open, setOpen] = React.useState(false);
const selectedHost = hosts.find((h) => h.id === jumpHost.hostId);
return (
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
<div className="flex items-center gap-2 flex-1">
<span className="text-sm font-medium text-muted-foreground">
{index + 1}.
</span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild className="flex-1">
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedHost
? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}`
: t("hosts.selectServer")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("hosts.searchServers")} />
<CommandEmpty>{t("hosts.noServerFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{hosts
.filter((h) => !editingHost || h.id !== editingHost.id)
.map((host) => (
<CommandItem
key={host.id}
value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
onSelect={() => {
onUpdate(host.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
jumpHost.hostId === host.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{host.name || `${host.username}@${host.ip}`}
</span>
<span className="text-xs text-muted-foreground">
{host.username}@{host.ip}:{host.port}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="ml-2"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import React from "react";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import { Check, ChevronsUpDown, X } from "lucide-react";
import type { QuickActionItemProps } from "./tab-types";
export function QuickActionItem({
quickAction,
index,
snippets,
onUpdate,
onRemove,
t,
}: QuickActionItemProps) {
const [open, setOpen] = React.useState(false);
const selectedSnippet = snippets.find((s) => s.id === quickAction.snippetId);
return (
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
<div className="flex flex-col gap-2 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">
{index + 1}.
</span>
<Input
placeholder={t("hosts.quickActionName")}
value={quickAction.name}
onChange={(e) => onUpdate(e.target.value, quickAction.snippetId)}
onBlur={(e) =>
onUpdate(e.target.value.trim(), quickAction.snippetId)
}
className="flex-1"
/>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild className="w-full">
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
>
{selectedSnippet
? selectedSnippet.name
: t("hosts.selectSnippet")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder={t("hosts.searchSnippets")} />
<CommandEmpty>{t("hosts.noSnippetFound")}</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
{snippets.map((snippet) => (
<CommandItem
key={snippet.id}
value={`${snippet.name} ${snippet.content} ${snippet.id}`}
onSelect={() => {
onUpdate(quickAction.name, snippet.id);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
quickAction.snippetId === snippet.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{snippet.name}</span>
<span className="text-xs text-muted-foreground truncate max-w-[350px]">
{snippet.content}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemove}
className="ml-2"
>
<X className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import type { UseFormReturn } from "react-hook-form";
import type React from "react";
import type { SSHHost, Credential } from "@/types";
export interface HostGeneralTabProps {
form: UseFormReturn<FormData>;
authTab: "password" | "key" | "credential" | "none";
setAuthTab: (value: "password" | "key" | "credential" | "none") => void;
keyInputMethod: "upload" | "paste";
setKeyInputMethod: (value: "upload" | "paste") => void;
proxyMode: "single" | "chain";
setProxyMode: (value: "single" | "chain") => void;
tagInput: string;
setTagInput: (value: string) => void;
folderDropdownOpen: boolean;
setFolderDropdownOpen: (value: boolean) => void;
folderInputRef: React.RefObject<HTMLInputElement>;
folderDropdownRef: React.RefObject<HTMLDivElement>;
filteredFolders: string[];
handleFolderClick: (folder: string) => void;
keyTypeDropdownOpen: boolean;
setKeyTypeDropdownOpen: (value: boolean) => void;
keyTypeButtonRef: React.RefObject<HTMLButtonElement>;
keyTypeDropdownRef: React.RefObject<HTMLDivElement>;
keyTypeOptions: Array<{ value: string; label: string }>;
ipInputRef: React.RefObject<HTMLInputElement>;
editorTheme: unknown;
hosts: SSHHost[];
editingHost?: SSHHost | null;
folders: string[];
credentials: Credential[];
t: (key: string) => string;
}
export interface HostTerminalTabProps {
form: UseFormReturn<FormData>;
snippets: Array<{ id: number; name: string; content: string }>;
t: (key: string) => string;
}
export interface HostDockerTabProps {
form: UseFormReturn<FormData>;
t: (key: string) => string;
}
export interface HostTunnelTabProps {
form: UseFormReturn<FormData>;
sshConfigDropdownOpen: { [key: number]: boolean };
setSshConfigDropdownOpen: React.Dispatch<
React.SetStateAction<{ [key: number]: boolean }>
>;
sshConfigInputRefs: React.MutableRefObject<{
[key: number]: HTMLInputElement | null;
}>;
sshConfigDropdownRefs: React.MutableRefObject<{
[key: number]: HTMLDivElement | null;
}>;
getFilteredSshConfigs: (index: number) => string[];
handleSshConfigClick: (config: string, index: number) => void;
t: (key: string) => string;
}
export interface HostFileManagerTabProps {
form: UseFormReturn<FormData>;
t: (key: string) => string;
}
export interface HostStatisticsTabProps {
form: UseFormReturn<FormData>;
statusIntervalUnit: "seconds" | "minutes";
setStatusIntervalUnit: (value: "seconds" | "minutes") => void;
metricsIntervalUnit: "seconds" | "minutes";
setMetricsIntervalUnit: (value: "seconds" | "minutes") => void;
snippets: Array<{ id: number; name: string; content: string }>;
t: (key: string) => string;
}
export interface HostSharingTabProps {
hostId: number | undefined;
isNewHost: boolean;
}
export interface JumpHostItemProps {
jumpHost: { hostId: number };
index: number;
hosts: SSHHost[];
editingHost?: SSHHost | null;
onUpdate: (hostId: number) => void;
onRemove: () => void;
t: (key: string) => string;
}
export interface QuickActionItemProps {
quickAction: { name: string; snippetId: number };
index: number;
snippets: Array<{ id: number; name: string; content: string }>;
onUpdate: (name: string, snippetId: number) => void;
onRemove: () => void;
t: (key: string) => string;
}

View File

@@ -1,621 +0,0 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
import {
getServerStatusById,
getServerMetricsById,
executeSnippet,
type ServerMetrics,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
type WidgetType,
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets";
import {
CpuWidget,
MemoryWidget,
DiskWidget,
NetworkWidget,
UptimeWidget,
ProcessesWidget,
SystemWidget,
LoginStatsWidget,
} from "./widgets";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
interface QuickAction {
name: string;
snippetId: number;
}
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
quickActions?: QuickAction[];
statsConfig?: string | StatsConfig;
[key: string]: unknown;
}
interface TabData {
id: number;
type: string;
title?: string;
hostConfig?: HostConfig;
[key: string]: unknown;
}
interface ServerProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
export function Server({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ServerProps): React.ReactElement {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const { addTab, tabs } = useTabs() as {
addTab: (tab: { type: string; [key: string]: unknown }) => number;
tabs: TabData[];
};
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
"offline",
);
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [metricsHistory, setMetricsHistory] = React.useState<ServerMetrics[]>(
[],
);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
new Set(),
);
const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
return DEFAULT_STATS_CONFIG;
}
try {
const parsed =
typeof currentHostConfig.statsConfig === "string"
? JSON.parse(currentHostConfig.statsConfig)
: currentHostConfig.statsConfig;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch (error) {
console.error("Failed to parse statsConfig:", error);
return DEFAULT_STATS_CONFIG;
}
}, [currentHostConfig?.statsConfig]);
const enabledWidgets = statsConfig.enabledWidgets;
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const metricsEnabled = statsConfig.metricsEnabled !== false;
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setServerStatus("offline");
setMetrics(null);
setMetricsHistory([]);
setShowStatsUI(true);
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
const renderWidget = (widgetType: WidgetType) => {
switch (widgetType) {
case "cpu":
return <CpuWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "memory":
return (
<MemoryWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "disk":
return <DiskWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "network":
return (
<NetworkWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "uptime":
return (
<UptimeWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "processes":
return (
<ProcessesWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "system":
return (
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "login_stats":
return (
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
);
default:
return null;
}
};
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
React.useEffect(() => {
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
setServerStatus("offline");
return;
}
let cancelled = false;
let intervalId: number | undefined;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) {
setServerStatus(res?.status === "online" ? "online" : "offline");
}
} catch (error: unknown) {
if (!cancelled) {
const err = error as {
response?: { status?: number };
};
if (err?.response?.status === 503) {
setServerStatus("offline");
} else if (err?.response?.status === 504) {
setServerStatus("offline");
} else if (err?.response?.status === 404) {
setServerStatus("offline");
} else {
setServerStatus("offline");
}
}
}
};
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [currentHostConfig?.id, isVisible, statusCheckEnabled]);
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id || !isVisible) {
setShowStatsUI(false);
return;
}
let cancelled = false;
let intervalId: number | undefined;
const fetchMetrics = async () => {
if (!currentHostConfig?.id) return;
try {
setIsLoadingMetrics(true);
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
return newHistory.slice(-20);
});
setShowStatsUI(true);
}
} catch (error: unknown) {
if (!cancelled) {
const err = error as {
code?: string;
response?: { status?: number; data?: { error?: string } };
};
if (err?.response?.status === 404) {
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.code === "TOTP_REQUIRED" ||
(err?.response?.status === 403 &&
err?.response?.data?.error === "TOTP_REQUIRED")
) {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.totpUnavailable"));
} else {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.failedToFetchMetrics"));
}
}
} finally {
if (!cancelled) {
setIsLoadingMetrics(false);
}
}
};
fetchMetrics();
intervalId = window.setInterval(fetchMetrics, 10000);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [currentHostConfig?.id, isVisible, metricsEnabled]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
const bottomMarginPx = 8;
const isFileManagerAlreadyOpen = React.useMemo(() => {
if (!currentHostConfig) return false;
return tabs.some(
(tab: TabData) =>
tab.type === "file_manager" &&
tab.hostConfig?.id === currentHostConfig.id,
);
}, [tabs, currentHostConfig]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-white overflow-hidden bg-transparent"
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
{statusCheckEnabled && (
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
disabled={isRefreshing}
className="font-semibold"
onClick={async () => {
if (currentHostConfig?.id) {
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(
res?.status === "online" ? "online" : "offline",
);
const data = await getServerMetricsById(
currentHostConfig.id,
);
setMetrics(data);
setShowStatsUI(true);
} catch (error: unknown) {
const err = error as {
code?: string;
status?: number;
response?: { status?: number; data?: { error?: string } };
};
if (
err?.code === "TOTP_REQUIRED" ||
(err?.response?.status === 403 &&
err?.response?.data?.error === "TOTP_REQUIRED")
) {
toast.error(t("serverStats.totpUnavailable"));
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.response?.status === 503 ||
err?.status === 503
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.response?.status === 504 ||
err?.status === 504
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.response?.status === 404 ||
err?.status === 404
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
}
} finally {
setIsRefreshing(false);
}
}
}}
title={t("serverStats.refreshStatusAndMetrics")}
>
{isRefreshing ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
{t("serverStats.refreshing")}
</div>
) : (
t("serverStats.refreshStatus")
)}
</Button>
{currentHostConfig?.enableFileManager && (
<Button
variant="outline"
className="font-semibold"
disabled={isFileManagerAlreadyOpen}
title={
isFileManagerAlreadyOpen
? t("serverStats.fileManagerAlreadyOpen")
: t("serverStats.openFileManager")
}
onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase =
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: "file_manager",
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
{t("nav.fileManager")}
</Button>
)}
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-y-auto min-h-0">
{(metricsEnabled && showStatsUI) ||
(currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0) ? (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 overflow-y-auto relative flex-1 flex flex-col">
{currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && (
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
<h3 className="text-sm font-semibold text-gray-400 mb-2">
{t("serverStats.quickActions")}
</h3>
<div className="flex flex-wrap gap-2">
{currentHostConfig.quickActions.map((action, index) => {
const isExecuting = executingActions.has(
action.snippetId,
);
return (
<Button
key={index}
variant="outline"
size="sm"
className="font-semibold"
disabled={isExecuting}
onClick={async () => {
if (!currentHostConfig) return;
setExecutingActions((prev) =>
new Set(prev).add(action.snippetId),
);
toast.loading(
t("serverStats.executingQuickAction", {
name: action.name,
}),
{ id: `quick-action-${action.snippetId}` },
);
try {
const result = await executeSnippet(
action.snippetId,
currentHostConfig.id,
);
if (result.success) {
toast.success(
t("serverStats.quickActionSuccess", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description: result.output
? result.output.substring(0, 200)
: undefined,
duration: 5000,
},
);
} else {
toast.error(
t("serverStats.quickActionFailed", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
result.error ||
result.output ||
undefined,
duration: 5000,
},
);
}
} catch (error: any) {
toast.error(
t("serverStats.quickActionError", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
error?.message || "Unknown error",
duration: 5000,
},
);
} finally {
setExecutingActions((prev) => {
const next = new Set(prev);
next.delete(action.snippetId);
return next;
});
}
}}
title={t("serverStats.executeQuickAction", {
name: action.name,
})}
>
{isExecuting ? (
<div className="flex items-center gap-2">
<div className="w-3 h-3 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
{action.name}
</div>
) : (
action.name
)}
</Button>
);
})}
</div>
</div>
)}
{metricsEnabled &&
showStatsUI &&
(!metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div>
<p className="text-gray-300 mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{enabledWidgets.map((widgetType) => (
<div key={widgetType} className="h-[280px]">
{renderWidget(widgetType)}
</div>
))}
</div>
))}
{metricsEnabled && showStatsUI && (
<SimpleLoader
visible={isLoadingMetrics && !metrics}
message={t("serverStats.loadingMetrics")}
/>
)}
</div>
) : null}
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,99 +0,0 @@
import React from "react";
import { HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } =
RechartsPrimitive;
interface DiskWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function DiskWidget({ metrics }: DiskWidgetProps) {
const { t } = useTranslation();
const radialData = React.useMemo(() => {
const percent = metrics?.disk?.percent || 0;
return [
{
name: "Disk",
value: percent,
fill: "#fb923c",
},
];
}, [metrics]);
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.diskUsage")}
</h3>
</div>
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="60%"
outerRadius="90%"
data={radialData}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis
type="number"
domain={[0, 100]}
angleAxisId={0}
tick={false}
/>
<RadialBar
background
dataKey="value"
cornerRadius={10}
fill="#fb923c"
/>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
className="text-2xl font-bold fill-orange-400"
>
{typeof metrics?.disk?.percent === "number"
? `${metrics.disk.percent}%`
: "N/A"}
</text>
</RadialBarChart>
</ResponsiveContainer>
</div>
<div className="flex-shrink-0 space-y-1 text-center pb-2">
<div className="text-xs text-gray-400">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
if (used && total) {
return `${used} / ${total}`;
}
return "N/A";
})()}
</div>
<div className="text-xs text-gray-500">
{(() => {
const available = metrics?.disk?.availableHuman;
return available
? `${t("serverStats.available")}: ${available}`
: `${t("serverStats.available")}: N/A`;
})()}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,8 +0,0 @@
export { CpuWidget } from "./CpuWidget";
export { MemoryWidget } from "./MemoryWidget";
export { DiskWidget } from "./DiskWidget";
export { NetworkWidget } from "./NetworkWidget";
export { UptimeWidget } from "./UptimeWidget";
export { ProcessesWidget } from "./ProcessesWidget";
export { SystemWidget } from "./SystemWidget";
export { LoginStatsWidget } from "./LoginStatsWidget";

View File

@@ -197,9 +197,12 @@ export function SSHToolsSidebar({
);
const [draggedSnippet, setDraggedSnippet] = useState<Snippet | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
new Set(),
);
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
return shouldCollapse ? new Set() : new Set();
});
const [snippetSearchQuery, setSnippetSearchQuery] = useState("");
const [showFolderDialog, setShowFolderDialog] = useState(false);
const [editingFolder, setEditingFolder] = useState<SnippetFolder | null>(
null,
@@ -243,9 +246,10 @@ export function SSHToolsSidebar({
const splittableTabs = tabs.filter(
(tab: TabData) =>
tab.type === "terminal" ||
tab.type === "server" ||
tab.type === "server_stats" ||
tab.type === "file_manager" ||
tab.type === "user_profile",
tab.type === "tunnel" ||
tab.type === "docker",
);
useEffect(() => {
@@ -281,9 +285,9 @@ export function SSHToolsSidebar({
console.error("Failed to fetch command history", err);
const errorMessage =
err?.response?.status === 401
? "Authentication required. Please refresh the page."
? t("commandHistory.authRequiredRefresh")
: err?.response?.status === 403
? "Data access locked. Please re-authenticate."
? t("commandHistory.dataAccessLockedReauth")
: err?.message || "Failed to load command history";
setHistoryError(errorMessage);
@@ -351,6 +355,55 @@ export function SSHToolsSidebar({
}
}, [isOpen, activeTab]);
useEffect(() => {
if (snippetFolders.length > 0) {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
if (shouldCollapse) {
const allFolderNames = new Set(snippetFolders.map((f) => f.name));
const uncategorizedSnippets = snippets.filter(
(s) => !s.folder || s.folder === "",
);
if (uncategorizedSnippets.length > 0) {
allFolderNames.add("");
}
setCollapsedFolders(allFolderNames);
} else {
setCollapsedFolders(new Set());
}
}
}, [snippetFolders, snippets]);
useEffect(() => {
const handleSettingChange = () => {
const shouldCollapse =
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
if (shouldCollapse) {
const allFolderNames = new Set(snippetFolders.map((f) => f.name));
const uncategorizedSnippets = snippets.filter(
(s) => !s.folder || s.folder === "",
);
if (uncategorizedSnippets.length > 0) {
allFolderNames.add("");
}
setCollapsedFolders(allFolderNames);
} else {
setCollapsedFolders(new Set());
}
};
window.addEventListener(
"defaultSnippetFoldersCollapsedChanged",
handleSettingChange,
);
return () => {
window.removeEventListener(
"defaultSnippetFoldersCollapsedChanged",
handleSettingChange,
);
};
}, [snippetFolders, snippets]);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
@@ -674,6 +727,11 @@ export function SSHToolsSidebar({
onSnippetExecute(snippet.content);
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
}
// Remove focus from any active element in the sidebar to prevent accidental re-execution
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
};
const handleCopy = (snippet: Snippet) => {
@@ -715,7 +773,22 @@ export function SSHToolsSidebar({
}
});
snippets.forEach((snippet) => {
const filteredSnippets = snippetSearchQuery
? snippets.filter(
(snippet) =>
snippet.name
.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()) ||
snippet.content
.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()) ||
snippet.description
?.toLowerCase()
.includes(snippetSearchQuery.toLowerCase()),
)
: snippets;
filteredSnippets.forEach((snippet) => {
const folderName = snippet.folder || "";
if (!grouped.has(folderName)) {
grouped.set(folderName, []);
@@ -757,11 +830,7 @@ export function SSHToolsSidebar({
const targetFolder = targetSnippet.folder || "";
if (sourceFolder !== targetFolder) {
toast.error(
t("snippets.reorderSameFolder", {
defaultValue: "Can only reorder snippets within the same folder",
}),
);
toast.error(t("snippets.reorderSameFolder"));
setDraggedSnippet(null);
setDragOverFolder(null);
return;
@@ -796,18 +865,10 @@ export function SSHToolsSidebar({
try {
await reorderSnippets(updates);
toast.success(
t("snippets.reorderSuccess", {
defaultValue: "Snippets reordered successfully",
}),
);
toast.success(t("snippets.reorderSuccess"));
fetchSnippets();
} catch {
toast.error(
t("snippets.reorderFailed", {
defaultValue: "Failed to reorder snippets",
}),
);
toast.error(t("snippets.reorderFailed"));
}
setDraggedSnippet(null);
@@ -845,23 +906,14 @@ export function SSHToolsSidebar({
confirmWithToast(
t("snippets.deleteFolderConfirm", {
name: folderName,
defaultValue: `Delete folder "${folderName}"? All snippets will be moved to Uncategorized.`,
}),
async () => {
try {
await deleteSnippetFolder(folderName);
toast.success(
t("snippets.deleteFolderSuccess", {
defaultValue: "Folder deleted successfully",
}),
);
toast.success(t("snippets.deleteFolderSuccess"));
fetchSnippets();
} catch {
toast.error(
t("snippets.deleteFolderFailed", {
defaultValue: "Failed to delete folder",
}),
);
toast.error(t("snippets.deleteFolderFailed"));
}
},
"destructive",
@@ -888,22 +940,14 @@ export function SSHToolsSidebar({
color: folderFormData.color || undefined,
icon: folderFormData.icon || undefined,
});
toast.success(
t("snippets.updateFolderSuccess", {
defaultValue: "Folder updated successfully",
}),
);
toast.success(t("snippets.updateFolderSuccess"));
} else {
await createSnippetFolder({
name: folderFormData.name,
color: folderFormData.color || undefined,
icon: folderFormData.icon || undefined,
});
toast.success(
t("snippets.createFolderSuccess", {
defaultValue: "Folder created successfully",
}),
);
toast.success(t("snippets.createFolderSuccess"));
}
setShowFolderDialog(false);
@@ -911,12 +955,8 @@ export function SSHToolsSidebar({
} catch {
toast.error(
editingFolder
? t("snippets.updateFolderFailed", {
defaultValue: "Failed to update folder",
})
: t("snippets.createFolderFailed", {
defaultValue: "Failed to create folder",
}),
? t("snippets.updateFolderFailed")
: t("snippets.createFolderFailed"),
);
}
};
@@ -985,11 +1025,7 @@ export function SSHToolsSidebar({
}
if (splitAssignments.size === 0) {
toast.error(
t("splitScreen.error.noAssignments", {
defaultValue: "Please drag tabs to cells before applying",
}),
);
toast.error(t("splitScreen.error.noAssignments"));
return;
}
@@ -998,7 +1034,6 @@ export function SSHToolsSidebar({
if (splitAssignments.size < requiredSlots) {
toast.error(
t("splitScreen.error.fillAllSlots", {
defaultValue: `Please fill all ${requiredSlots} layout spots before applying`,
count: requiredSlots,
}),
);
@@ -1026,11 +1061,7 @@ export function SSHToolsSidebar({
setCurrentTab(orderedTabIds[0]);
}
toast.success(
t("splitScreen.success", {
defaultValue: "Split screen applied",
}),
);
toast.success(t("splitScreen.success"));
};
const handleClearSplit = () => {
@@ -1042,11 +1073,7 @@ export function SSHToolsSidebar({
setSplitAssignments(new Map());
setPreviewKey((prev) => prev + 1);
toast.success(
t("splitScreen.cleared", {
defaultValue: "Split screen cleared",
}),
);
toast.success(t("splitScreen.cleared"));
};
const handleResetToSingle = () => {
@@ -1064,17 +1091,9 @@ export function SSHToolsSidebar({
try {
await deleteCommandFromHistory(activeTerminalHostId, command);
setCommandHistory((prev) => prev.filter((c) => c !== command));
toast.success(
t("commandHistory.deleteSuccess", {
defaultValue: "Command deleted from history",
}),
);
toast.success(t("commandHistory.deleteSuccess"));
} catch {
toast.error(
t("commandHistory.deleteFailed", {
defaultValue: "Failed to delete command.",
}),
);
toast.error(t("commandHistory.deleteFailed"));
}
}
};
@@ -1096,14 +1115,14 @@ export function SSHToolsSidebar({
className="pointer-events-auto"
>
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
<SidebarGroupLabel className="text-lg font-bold text-foreground">
{t("nav.tools")}
<div className="absolute right-5 flex gap-1">
<Button
variant="outline"
onClick={() => setSidebarWidth(400)}
className="w-[28px] h-[28px]"
title="Reset sidebar width"
title={t("common.resetSidebarWidth")}
>
<RotateCcw className="h-4 w-4" />
</Button>
@@ -1133,15 +1152,15 @@ export function SSHToolsSidebar({
{t("snippets.title")}
</TabsTrigger>
<TabsTrigger value="command-history">
{t("commandHistory.title", { defaultValue: "History" })}
{t("commandHistory.title")}
</TabsTrigger>
<TabsTrigger value="split-screen">
{t("splitScreen.title", { defaultValue: "Split Screen" })}
{t("splitScreen.title")}
</TabsTrigger>
</TabsList>
<TabsContent value="ssh-tools" className="space-y-4">
<h3 className="font-semibold text-white">
<h3 className="font-semibold text-foreground">
{t("sshTools.keyRecording")}
</h3>
@@ -1169,10 +1188,10 @@ export function SSHToolsSidebar({
{isRecording && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
<label className="text-sm font-medium text-foreground">
{t("sshTools.selectTerminals")}
</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto thin-scrollbar">
{terminalTabs.map((tab) => (
<Button
key={tab.id}
@@ -1181,8 +1200,8 @@ export function SSHToolsSidebar({
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id)
? "text-white bg-gray-700"
: "text-gray-500"
? "text-foreground bg-surface"
: "text-foreground-subtle"
}`}
onClick={() => handleTabToggle(tab.id)}
>
@@ -1193,7 +1212,7 @@ export function SSHToolsSidebar({
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
<label className="text-sm font-medium text-foreground">
{t("sshTools.typeCommands")}
</label>
<Input
@@ -1217,7 +1236,7 @@ export function SSHToolsSidebar({
<Separator />
<h3 className="font-semibold text-white">
<h3 className="font-semibold text-foreground">
{t("sshTools.settings")}
</h3>
@@ -1229,7 +1248,7 @@ export function SSHToolsSidebar({
/>
<label
htmlFor="enable-copy-paste"
className="text-sm font-medium leading-none text-white cursor-pointer"
className="text-sm font-medium leading-none text-foreground cursor-pointer"
>
{t("sshTools.enableRightClickCopyPaste")}
</label>
@@ -1244,23 +1263,17 @@ export function SSHToolsSidebar({
{terminalTabs.length > 0 && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("snippets.selectTerminals", {
defaultValue: "Select Terminals (optional)",
})}
<label className="text-sm font-medium text-foreground">
{t("snippets.selectTerminals")}
</label>
<p className="text-xs text-muted-foreground">
{selectedSnippetTabIds.length > 0
? t("snippets.executeOnSelected", {
defaultValue: `Execute on ${selectedSnippetTabIds.length} selected terminal(s)`,
count: selectedSnippetTabIds.length,
})
: t("snippets.executeOnCurrent", {
defaultValue:
"Execute on current terminal (click to select multiple)",
})}
: t("snippets.executeOnCurrent")}
</p>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto thin-scrollbar">
{terminalTabs.map((tab) => (
<Button
key={tab.id}
@@ -1269,8 +1282,8 @@ export function SSHToolsSidebar({
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedSnippetTabIds.includes(tab.id)
? "text-white bg-gray-700"
: "text-gray-500"
? "text-foreground bg-surface"
: "text-foreground-subtle"
}`}
onClick={() => handleSnippetTabToggle(tab.id)}
>
@@ -1283,6 +1296,28 @@ export function SSHToolsSidebar({
</>
)}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("snippets.searchSnippets")}
value={snippetSearchQuery}
onChange={(e) => {
setSnippetSearchQuery(e.target.value);
}}
className="pl-10 pr-10"
/>
{snippetSearchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => setSnippetSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex gap-2">
<Button
onClick={handleCreate}
@@ -1298,9 +1333,7 @@ export function SSHToolsSidebar({
variant="outline"
>
<FolderPlus className="w-4 h-4 mr-2" />
{t("snippets.newFolder", {
defaultValue: "New Folder",
})}
{t("snippets.newFolder")}
</Button>
</div>
</div>
@@ -1318,7 +1351,7 @@ export function SSHToolsSidebar({
</div>
) : (
<TooltipProvider>
<div className="space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="space-y-3 overflow-y-auto flex-1 min-h-0 thin-scrollbar">
{Array.from(groupSnippetsByFolder()).map(
([folderName, folderSnippets]) => {
const folderMetadata = snippetFolders.find(
@@ -1329,7 +1362,7 @@ export function SSHToolsSidebar({
return (
<div key={folderName || "uncategorized"}>
<div className="flex items-center gap-2 mb-2 hover:bg-dark-hover-alt p-2 rounded-lg transition-colors group/folder">
<div className="flex items-center gap-2 mb-2 hover:bg-hover-alt p-2 rounded-lg transition-colors group/folder">
<div
className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => toggleFolder(folderName)}
@@ -1420,7 +1453,7 @@ export function SSHToolsSidebar({
}
onDrop={(e) => handleDrop(e, snippet)}
onDragEnd={handleDragEnd}
className={`bg-dark-bg-input border border-input rounded-lg cursor-move hover:shadow-lg hover:border-gray-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group ${
className={`bg-field border border-input rounded-lg cursor-move hover:shadow-lg hover:border-edge-hover hover:bg-hover-alt transition-all duration-200 p-3 group ${
draggedSnippet?.id === snippet.id
? "opacity-50"
: ""
@@ -1429,7 +1462,7 @@ export function SSHToolsSidebar({
<div className="mb-2 flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0 opacity-50 group-hover:opacity-100 transition-opacity" />
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-white mb-1">
<h3 className="text-sm font-medium text-foreground mb-1">
{snippet.name}
</h3>
{snippet.description && (
@@ -1550,9 +1583,7 @@ export function SSHToolsSidebar({
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("commandHistory.searchPlaceholder", {
defaultValue: "Search commands...",
})}
placeholder={t("commandHistory.searchPlaceholder")}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
@@ -1571,10 +1602,7 @@ export function SSHToolsSidebar({
)}
</div>
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
{t("commandHistory.tabHint", {
defaultValue:
"Use Tab in Terminal to autocomplete from command history",
})}
{t("commandHistory.tabHint")}
</p>
</div>
@@ -1583,9 +1611,7 @@ export function SSHToolsSidebar({
<div className="text-center py-8">
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-4">
<p className="text-destructive font-medium mb-2">
{t("commandHistory.error", {
defaultValue: "Error loading history",
})}
{t("commandHistory.error")}
</p>
<p className="text-sm text-muted-foreground">
{historyError}
@@ -1597,31 +1623,24 @@ export function SSHToolsSidebar({
}
variant="outline"
>
{t("common.retry", { defaultValue: "Retry" })}
{t("common.retry")}
</Button>
</div>
) : !activeTerminal ? (
<div className="text-center text-muted-foreground py-8">
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
<p className="mb-2 font-medium">
{t("commandHistory.noTerminal", {
defaultValue: "No active terminal",
})}
{t("commandHistory.noTerminal")}{" "}
</p>
<p className="text-sm">
{t("commandHistory.noTerminalHint", {
defaultValue:
"Open a terminal to see its command history.",
})}
{t("commandHistory.noTerminalHint")}
</p>
</div>
) : isHistoryLoading && commandHistory.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Loader2 className="h-12 w-12 mb-4 opacity-20 mx-auto animate-spin" />
<p className="mb-2 font-medium">
{t("commandHistory.loading", {
defaultValue: "Loading command history...",
})}
{t("commandHistory.loading")}{" "}
</p>
</div>
) : filteredCommands.length === 0 ? (
@@ -1630,13 +1649,10 @@ export function SSHToolsSidebar({
<>
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
<p className="mb-2 font-medium">
{t("commandHistory.noResults", {
defaultValue: "No commands found",
})}
{t("commandHistory.noResults")}
</p>
<p className="text-sm">
{t("commandHistory.noResultsHint", {
defaultValue: `No commands matching "${searchQuery}"`,
query: searchQuery,
})}
</p>
@@ -1644,15 +1660,10 @@ export function SSHToolsSidebar({
) : (
<>
<p className="mb-2 font-medium">
{t("commandHistory.empty", {
defaultValue: "No command history yet",
})}
{t("commandHistory.empty")}
</p>
<p className="text-sm">
{t("commandHistory.emptyHint", {
defaultValue:
"Execute commands in the active terminal to build its history.",
})}
{t("commandHistory.emptyHint")}
</p>
</>
)}
@@ -1660,16 +1671,16 @@ export function SSHToolsSidebar({
) : (
<div
ref={commandHistoryScrollRef}
className="space-y-2 overflow-y-auto h-full"
className="space-y-2 overflow-y-auto h-full thin-scrollbar"
>
{filteredCommands.map((command, index) => (
<div
key={index}
className="bg-dark-bg border-2 border-dark-border rounded-md px-3 py-2.5 hover:bg-dark-hover-alt hover:border-gray-600 transition-all duration-200 group h-12 flex items-center"
className="bg-canvas border-2 border-edge rounded-md px-3 py-2.5 hover:bg-hover-alt hover:border-edge-hover transition-all duration-200 group h-12 flex items-center"
>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span
className="flex-1 font-mono text-sm cursor-pointer text-white truncate"
className="flex-1 font-mono text-sm cursor-pointer text-foreground truncate"
onClick={() => handleCommandSelect(command)}
title={command}
>
@@ -1683,9 +1694,7 @@ export function SSHToolsSidebar({
e.stopPropagation();
handleCommandDelete(command);
}}
title={t("commandHistory.deleteTooltip", {
defaultValue: "Delete command",
})}
title={t("commandHistory.deleteTooltip")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -1701,7 +1710,7 @@ export function SSHToolsSidebar({
value="split-screen"
className="flex flex-col flex-1 overflow-hidden"
>
<div className="space-y-4 flex-1 overflow-y-auto overflow-x-hidden pb-4">
<div className="space-y-4 flex-1 overflow-y-auto overflow-x-hidden pb-4 thin-scrollbar">
<Tabs
value={splitMode}
onValueChange={(value) =>
@@ -1713,22 +1722,16 @@ export function SSHToolsSidebar({
>
<TabsList className="w-full grid grid-cols-4">
<TabsTrigger value="none">
{t("splitScreen.none", { defaultValue: "None" })}
{t("splitScreen.none")}
</TabsTrigger>
<TabsTrigger value="2">
{t("splitScreen.twoSplit", {
defaultValue: "2-Split",
})}
{t("splitScreen.twoSplit")}{" "}
</TabsTrigger>
<TabsTrigger value="3">
{t("splitScreen.threeSplit", {
defaultValue: "3-Split",
})}
{t("splitScreen.threeSplit")}{" "}
</TabsTrigger>
<TabsTrigger value="4">
{t("splitScreen.fourSplit", {
defaultValue: "4-Split",
})}
{t("splitScreen.fourSplit")}{" "}
</TabsTrigger>
</TabsList>
</Tabs>
@@ -1738,18 +1741,13 @@ export function SSHToolsSidebar({
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("splitScreen.availableTabs", {
defaultValue: "Available Tabs",
})}
<label className="text-sm font-medium text-foreground">
{t("splitScreen.availableTabs")}
</label>
<p className="text-xs text-muted-foreground mb-2">
{t("splitScreen.dragTabsHint", {
defaultValue:
"Drag tabs into the grid below to position them",
})}
{t("splitScreen.dragTabsHint")}
</p>
<div className="space-y-1 max-h-[200px] overflow-y-auto">
<div className="space-y-1 max-h-[200px] overflow-y-auto thin-scrollbar">
{splittableTabs.map((tab) => {
const isAssigned = Array.from(
splitAssignments.values(),
@@ -1768,8 +1766,8 @@ export function SSHToolsSidebar({
px-3 py-2 rounded-md text-sm cursor-move transition-all
${
isAssigned
? "bg-dark-bg/50 text-muted-foreground cursor-not-allowed opacity-50"
: "bg-dark-bg border border-dark-border hover:border-gray-400 hover:bg-dark-bg-input"
? "bg-canvas/50 text-muted-foreground cursor-not-allowed opacity-50"
: "bg-canvas border border-edge hover:border-edge-hover hover:bg-field"
}
${isDragging ? "opacity-50" : ""}
`}
@@ -1786,13 +1784,11 @@ export function SSHToolsSidebar({
<Separator />
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("splitScreen.layout", {
defaultValue: "Layout",
})}
<label className="text-sm font-medium text-foreground">
{t("splitScreen.layout")}
</label>
<div
className={`grid gap-2 ${
className={`grid gap-2 mt-2 ${
splitMode === "2"
? "grid-cols-2"
: splitMode === "3"
@@ -1806,7 +1802,9 @@ export function SSHToolsSidebar({
const assignedTabId =
splitAssignments.get(idx);
const assignedTab = assignedTabId
? tabs.find((t) => t.id === assignedTabId)
? splittableTabs.find(
(t) => t.id === assignedTabId,
)
: null;
const isHovered = dragOverCellIndex === idx;
const isEmpty = !assignedTabId;
@@ -1820,24 +1818,24 @@ export function SSHToolsSidebar({
onDragLeave={handleTabDragLeave}
onDrop={() => handleTabDrop(idx)}
className={`
relative bg-dark-bg border-2 rounded-md p-3 min-h-[100px]
relative bg-canvas border-2 rounded-md p-3 min-h-[100px]
flex flex-col items-center justify-center transition-all
${splitMode === "3" && idx === 2 ? "col-span-2" : ""}
${
isEmpty
? "border-dashed border-dark-border"
: "border-solid border-gray-400 bg-gray-500/10"
? "border-dashed border-edge"
: "border-solid border-edge-hover bg-surface"
}
${
isHovered && draggedTabId
? "border-gray-500 bg-gray-500/20 ring-2 ring-gray-500/50"
? "border-edge-hover bg-surface ring-2 ring-edge-hover"
: ""
}
`}
>
{assignedTab ? (
<>
<span className="text-sm text-white truncate w-full text-center mb-2">
<span className="text-sm text-foreground truncate w-full text-center mb-2">
{assignedTab.title}
</span>
<Button
@@ -1848,14 +1846,12 @@ export function SSHToolsSidebar({
}
className="h-6 text-xs hover:bg-red-500/20"
>
Remove
{t("common.remove")}
</Button>
</>
) : (
<span className="text-xs text-muted-foreground">
{t("splitScreen.dropHere", {
defaultValue: "Drop tab here",
})}
{t("splitScreen.dropHere")}
</span>
)}
</div>
@@ -1871,18 +1867,14 @@ export function SSHToolsSidebar({
className="flex-1"
disabled={splitAssignments.size === 0}
>
{t("splitScreen.apply", {
defaultValue: "Apply Split",
})}
{t("splitScreen.apply")}
</Button>
<Button
variant="outline"
onClick={handleClearSplit}
className="flex-1"
>
{t("splitScreen.clear", {
defaultValue: "Clear",
})}
{t("splitScreen.clear")}
</Button>
</div>
</>
@@ -1892,16 +1884,10 @@ export function SSHToolsSidebar({
<div className="text-center py-8">
<LayoutGrid className="h-12 w-12 mb-4 opacity-20 mx-auto" />
<p className="text-sm text-muted-foreground mb-2">
{t("splitScreen.selectMode", {
defaultValue:
"Select a split mode to get started",
})}
{t("splitScreen.selectMode")}
</p>
<p className="text-xs text-muted-foreground">
{t("splitScreen.helpText", {
defaultValue:
"Choose how many tabs you want to display at once",
})}
{t("splitScreen.helpText")}
</p>
</div>
)}
@@ -1914,16 +1900,16 @@ export function SSHToolsSidebar({
className="absolute top-0 h-full cursor-col-resize z-[60]"
onMouseDown={handleMouseDown}
style={{
left: "-8px",
width: "18px",
left: "-4px",
width: "8px",
backgroundColor: isResizing
? "var(--dark-active)"
? "var(--bg-active)"
: "transparent",
}}
onMouseEnter={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor =
"var(--dark-border-hover)";
"var(--border-hover)";
}
}}
onMouseLeave={(e) => {
@@ -1931,7 +1917,7 @@ export function SSHToolsSidebar({
e.currentTarget.style.backgroundColor = "transparent";
}
}}
title="Drag to resize sidebar"
title={t("common.dragToResizeSidebar")}
/>
)}
</Sidebar>
@@ -1945,11 +1931,11 @@ export function SSHToolsSidebar({
onClick={() => setShowDialog(false)}
>
<div
className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
className="bg-canvas border-2 border-edge rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto thin-scrollbar"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<h2 className="text-xl font-semibold text-white">
<h2 className="text-xl font-semibold text-foreground">
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
@@ -1961,7 +1947,7 @@ export function SSHToolsSidebar({
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
<label className="text-sm font-medium text-foreground flex items-center gap-1">
{t("snippets.name")}
<span className="text-destructive">*</span>
</label>
@@ -1982,7 +1968,7 @@ export function SSHToolsSidebar({
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
<label className="text-sm font-medium text-foreground">
{t("snippets.description")}
<span className="text-muted-foreground ml-1">
({t("common.optional")})
@@ -1998,9 +1984,9 @@ export function SSHToolsSidebar({
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-2">
<label className="text-sm font-medium text-foreground flex items-center gap-2">
<Folder className="h-4 w-4" />
{t("snippets.folder", { defaultValue: "Folder" })}
{t("snippets.folder")}
<span className="text-muted-foreground">
({t("common.optional")})
</span>
@@ -2015,17 +2001,11 @@ export function SSHToolsSidebar({
}
>
<SelectTrigger>
<SelectValue
placeholder={t("snippets.selectFolder", {
defaultValue: "Select a folder or leave empty",
})}
/>
<SelectValue placeholder={t("snippets.selectFolder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__no_folder__">
{t("snippets.noFolder", {
defaultValue: "No folder (Uncategorized)",
})}
{t("snippets.noFolder")}
</SelectItem>
{snippetFolders.map((folder) => {
const FolderIcon = getFolderIcon(folder.name);
@@ -2048,7 +2028,7 @@ export function SSHToolsSidebar({
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
<label className="text-sm font-medium text-foreground flex items-center gap-1">
{t("snippets.content")}
<span className="text-destructive">*</span>
</label>
@@ -2093,32 +2073,26 @@ export function SSHToolsSidebar({
onClick={() => setShowFolderDialog(false)}
>
<div
className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-lg w-full mx-4"
className="bg-canvas border-2 border-edge rounded-lg p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<h2 className="text-xl font-semibold text-white">
<h2 className="text-xl font-semibold text-foreground">
{editingFolder
? t("snippets.editFolder", { defaultValue: "Edit Folder" })
: t("snippets.createFolder", {
defaultValue: "Create Folder",
})}
? t("snippets.editFolder")
: t("snippets.createFolder")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{editingFolder
? t("snippets.editFolderDescription", {
defaultValue: "Customize your snippet folder",
})
: t("snippets.createFolderDescription", {
defaultValue: "Organize your snippets into folders",
})}
? t("snippets.editFolderDescription")
: t("snippets.createFolderDescription")}
</p>
</div>
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{t("snippets.folderName", { defaultValue: "Folder Name" })}
<label className="text-sm font-medium text-foreground flex items-center gap-1">
{t("snippets.folderName")}
<span className="text-destructive">*</span>
</label>
<Input
@@ -2129,24 +2103,20 @@ export function SSHToolsSidebar({
name: e.target.value,
})
}
placeholder={t("snippets.folderNamePlaceholder", {
defaultValue: "e.g., System Commands, Docker Scripts",
})}
placeholder={t("sshTools.scripts.inputPlaceholder")}
className={`${folderFormErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`}
autoFocus
/>
{folderFormErrors.name && (
<p className="text-xs text-destructive mt-1">
{t("snippets.folderNameRequired", {
defaultValue: "Folder name is required",
})}
{t("snippets.folderNameRequired")}
</p>
)}
</div>
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
{t("snippets.folderColor", { defaultValue: "Folder Color" })}
<Label className="text-base font-semibold text-foreground">
{t("snippets.folderColor")}
</Label>
<div className="grid grid-cols-4 gap-3">
{AVAILABLE_COLORS.map((color) => (
@@ -2156,7 +2126,7 @@ export function SSHToolsSidebar({
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
folderFormData.color === color.value
? "border-white shadow-lg scale-105"
: "border-dark-border"
: "border-edge"
}`}
style={{ backgroundColor: color.value }}
onClick={() =>
@@ -2172,8 +2142,8 @@ export function SSHToolsSidebar({
</div>
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
{t("snippets.folderIcon", { defaultValue: "Folder Icon" })}
<Label className="text-base font-semibold text-foreground">
{t("snippets.folderIcon")}
</Label>
<div className="grid grid-cols-5 gap-3">
{AVAILABLE_ICONS.map(({ value, label, Icon }) => (
@@ -2183,7 +2153,7 @@ export function SSHToolsSidebar({
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
folderFormData.icon === value
? "border-primary bg-primary/10"
: "border-dark-border bg-dark-bg-darker"
: "border-edge bg-elevated"
}`}
onClick={() =>
setFolderFormData({ ...folderFormData, icon: value })
@@ -2197,10 +2167,10 @@ export function SSHToolsSidebar({
</div>
<div className="space-y-3">
<Label className="text-base font-semibold text-white">
{t("snippets.preview", { defaultValue: "Preview" })}
<Label className="text-base font-semibold text-foreground">
{t("snippets.preview")}
</Label>
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
<div className="flex items-center gap-3 p-4 rounded-md bg-elevated border border-edge">
{(() => {
const IconComponent =
AVAILABLE_ICONS.find(
@@ -2214,8 +2184,7 @@ export function SSHToolsSidebar({
);
})()}
<span className="font-medium">
{folderFormData.name ||
t("snippets.folderName", { defaultValue: "Folder Name" })}
{folderFormData.name || t("snippets.folderName")}
</span>
</div>
</div>
@@ -2233,12 +2202,8 @@ export function SSHToolsSidebar({
</Button>
<Button onClick={handleFolderSubmit} className="flex-1">
{editingFolder
? t("snippets.updateFolder", {
defaultValue: "Update Folder",
})
: t("snippets.createFolder", {
defaultValue: "Create Folder",
})}
? t("snippets.updateFolder")
: t("snippets.createFolder")}
</Button>
</div>
</div>