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.
This commit is contained in:
@@ -1600,6 +1600,15 @@
|
|||||||
"commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history",
|
"commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history",
|
||||||
"defaultSnippetFoldersCollapsed": "Collapse Snippet Folders by Default",
|
"defaultSnippetFoldersCollapsed": "Collapse Snippet Folders by Default",
|
||||||
"defaultSnippetFoldersCollapsedDesc": "When enabled, all snippet folders will be collapsed when you open the snippets tab",
|
"defaultSnippetFoldersCollapsedDesc": "When enabled, all snippet folders will be collapsed when you open the snippets tab",
|
||||||
|
"showHostTags": "Show Host Tags",
|
||||||
|
"showHostTagsDesc": "Display tags under each host in the sidebar. Disable to hide all tags.",
|
||||||
|
"account": "Account",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"languageLocalization": "Language & Localization",
|
||||||
|
"fileManagerSettings": "File Manager",
|
||||||
|
"terminalSettings": "Terminal",
|
||||||
|
"hostSidebarSettings": "Host & Sidebar",
|
||||||
|
"snippetsSettings": "Snippets",
|
||||||
"currentPassword": "Current Password",
|
"currentPassword": "Current Password",
|
||||||
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
||||||
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
|
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
|
||||||
@@ -1642,7 +1651,7 @@
|
|||||||
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
|
"searchHosts": "Search hosts by name, username, IP, folder, tags...",
|
||||||
"enterPassword": "Enter your password",
|
"enterPassword": "Enter your password",
|
||||||
"totpCode": "6-digit TOTP code",
|
"totpCode": "6-digit TOTP code",
|
||||||
"searchHostsAny": "Search hosts by any info...",
|
"searchHostsAny": "Search hosts (try: tag:prod, user:root, ip:192.168)...",
|
||||||
"confirmPassword": "Enter your password to confirm",
|
"confirmPassword": "Enter your password to confirm",
|
||||||
"typeHere": "Type here",
|
"typeHere": "Type here",
|
||||||
"fileName": "Enter file name (e.g., example.txt)",
|
"fileName": "Enter file name (e.g., example.txt)",
|
||||||
|
|||||||
@@ -616,7 +616,19 @@ export function Dashboard({
|
|||||||
{t("dashboard.noRecentActivity")}
|
{t("dashboard.noRecentActivity")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
recentActivity.map((item) => (
|
recentActivity
|
||||||
|
.filter((item, index, array) => {
|
||||||
|
// Always show the first item
|
||||||
|
if (index === 0) return true;
|
||||||
|
|
||||||
|
// Show if different from previous item (by hostId and type)
|
||||||
|
const prevItem = array[index - 1];
|
||||||
|
return !(
|
||||||
|
item.hostId === prevItem.hostId &&
|
||||||
|
item.type === prevItem.type
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((item) => (
|
||||||
<Button
|
<Button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
credentialId: currentHost.credentialId,
|
credentialId: currentHost.credentialId,
|
||||||
userId: currentHost.userId,
|
userId: currentHost.userId,
|
||||||
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
|
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
|
||||||
|
jumpHosts: currentHost.jumpHosts,
|
||||||
useSocks5: currentHost.useSocks5,
|
useSocks5: currentHost.useSocks5,
|
||||||
socks5Host: currentHost.socks5Host,
|
socks5Host: currentHost.socks5Host,
|
||||||
socks5Port: currentHost.socks5Port,
|
socks5Port: currentHost.socks5Port,
|
||||||
@@ -774,6 +775,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
sshKey: currentHost.key,
|
sshKey: currentHost.key,
|
||||||
keyPassword: currentHost.keyPassword,
|
keyPassword: currentHost.keyPassword,
|
||||||
credentialId: currentHost.credentialId,
|
credentialId: currentHost.credentialId,
|
||||||
|
jumpHosts: currentHost.jumpHosts,
|
||||||
useSocks5: currentHost.useSocks5,
|
useSocks5: currentHost.useSocks5,
|
||||||
socks5Host: currentHost.socks5Host,
|
socks5Host: currentHost.socks5Host,
|
||||||
socks5Port: currentHost.socks5Port,
|
socks5Port: currentHost.socks5Port,
|
||||||
@@ -1325,6 +1327,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
authType: currentHost.authType,
|
authType: currentHost.authType,
|
||||||
credentialId: currentHost.credentialId,
|
credentialId: currentHost.credentialId,
|
||||||
userId: currentHost.userId,
|
userId: currentHost.userId,
|
||||||
|
jumpHosts: currentHost.jumpHosts,
|
||||||
useSocks5: currentHost.useSocks5,
|
useSocks5: currentHost.useSocks5,
|
||||||
socks5Host: currentHost.socks5Host,
|
socks5Host: currentHost.socks5Host,
|
||||||
socks5Port: currentHost.socks5Port,
|
socks5Port: currentHost.socks5Port,
|
||||||
@@ -1482,6 +1485,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
|||||||
authType: credentials.password ? "password" : "key",
|
authType: credentials.password ? "password" : "key",
|
||||||
credentialId: currentHost.credentialId,
|
credentialId: currentHost.credentialId,
|
||||||
userId: currentHost.userId,
|
userId: currentHost.userId,
|
||||||
|
jumpHosts: currentHost.jumpHosts,
|
||||||
useSocks5: currentHost.useSocks5,
|
useSocks5: currentHost.useSocks5,
|
||||||
socks5Host: currentHost.socks5Host,
|
socks5Host: currentHost.socks5Host,
|
||||||
socks5Port: currentHost.socks5Port,
|
socks5Port: currentHost.socks5Port,
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export function TerminalWindow({
|
|||||||
|
|
||||||
const handleMaximize = () => {
|
const handleMaximize = () => {
|
||||||
maximizeWindow(windowId);
|
maximizeWindow(windowId);
|
||||||
|
// Trigger resize after maximize/restore
|
||||||
|
if (resizeTimeoutRef.current) {
|
||||||
|
clearTimeout(resizeTimeoutRef.current);
|
||||||
|
}
|
||||||
|
resizeTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (terminalRef.current?.fit) {
|
||||||
|
terminalRef.current.fit();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ function QuickActionItem({
|
|||||||
placeholder={t("hosts.quickActionName")}
|
placeholder={t("hosts.quickActionName")}
|
||||||
value={quickAction.name}
|
value={quickAction.name}
|
||||||
onChange={(e) => onUpdate(e.target.value, quickAction.snippetId)}
|
onChange={(e) => onUpdate(e.target.value, quickAction.snippetId)}
|
||||||
|
onBlur={(e) => onUpdate(e.target.value.trim(), quickAction.snippetId)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1196,6 +1197,10 @@ export function HostManagerEditor({
|
|||||||
field.ref(e);
|
field.ref(e);
|
||||||
ipInputRef.current = e;
|
ipInputRef.current = e;
|
||||||
}}
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -1238,6 +1243,10 @@ export function HostManagerEditor({
|
|||||||
placeholder={t("placeholders.username")}
|
placeholder={t("placeholders.username")}
|
||||||
disabled={shouldDisable}
|
disabled={shouldDisable}
|
||||||
{...field}
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -1259,6 +1268,10 @@ export function HostManagerEditor({
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("placeholders.hostname")}
|
placeholder={t("placeholders.hostname")}
|
||||||
{...field}
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -1283,6 +1296,10 @@ export function HostManagerEditor({
|
|||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
setFolderDropdownOpen(true);
|
setFolderDropdownOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{folderDropdownOpen && filteredFolders.length > 0 && (
|
{folderDropdownOpen && filteredFolders.length > 0 && (
|
||||||
@@ -1878,6 +1895,10 @@ export function HostManagerEditor({
|
|||||||
<Input
|
<Input
|
||||||
placeholder="proxy.example.com"
|
placeholder="proxy.example.com"
|
||||||
{...field}
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -1927,6 +1948,10 @@ export function HostManagerEditor({
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("hosts.username")}
|
placeholder={t("hosts.username")}
|
||||||
{...field}
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -2047,6 +2072,23 @@ export function HostManagerEditor({
|
|||||||
newChain,
|
newChain,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const currentChain =
|
||||||
|
form.watch(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
) || [];
|
||||||
|
const newChain = [
|
||||||
|
...currentChain,
|
||||||
|
];
|
||||||
|
newChain[index] = {
|
||||||
|
...newChain[index],
|
||||||
|
host: e.target.value.trim(),
|
||||||
|
};
|
||||||
|
form.setValue(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
newChain,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2142,6 +2184,23 @@ export function HostManagerEditor({
|
|||||||
newChain,
|
newChain,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const currentChain =
|
||||||
|
form.watch(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
) || [];
|
||||||
|
const newChain = [
|
||||||
|
...currentChain,
|
||||||
|
];
|
||||||
|
newChain[index] = {
|
||||||
|
...newChain[index],
|
||||||
|
username: e.target.value.trim(),
|
||||||
|
};
|
||||||
|
form.setValue(
|
||||||
|
"socks5ProxyChain",
|
||||||
|
newChain,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2694,6 +2753,10 @@ export function HostManagerEditor({
|
|||||||
<Input
|
<Input
|
||||||
placeholder="mosh user@server"
|
placeholder="mosh user@server"
|
||||||
{...field}
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -2769,6 +2832,10 @@ export function HostManagerEditor({
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Variable name"
|
placeholder="Variable name"
|
||||||
{...field}
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -2780,7 +2847,14 @@ export function HostManagerEditor({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex-1">
|
<FormItem className="flex-1">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Value" {...field} />
|
<Input
|
||||||
|
placeholder="Value"
|
||||||
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -3057,6 +3131,10 @@ export function HostManagerEditor({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
endpointHostField.onChange(e.target.value.trim());
|
||||||
|
endpointHostField.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{sshConfigDropdownOpen[index] &&
|
{sshConfigDropdownOpen[index] &&
|
||||||
@@ -3244,6 +3322,10 @@ export function HostManagerEditor({
|
|||||||
<Input
|
<Input
|
||||||
placeholder={t("placeholders.homePath")}
|
placeholder={t("placeholders.homePath")}
|
||||||
{...field}
|
{...field}
|
||||||
|
onBlur={(e) => {
|
||||||
|
field.onChange(e.target.value.trim());
|
||||||
|
field.onBlur();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
FolderMinus,
|
FolderMinus,
|
||||||
Copy,
|
Copy,
|
||||||
Activity,
|
|
||||||
Clock,
|
|
||||||
Palette,
|
Palette,
|
||||||
Trash,
|
Trash,
|
||||||
Cloud,
|
Cloud,
|
||||||
@@ -63,6 +61,8 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Share2,
|
Share2,
|
||||||
Users,
|
Users,
|
||||||
|
ArrowDownUp,
|
||||||
|
Container,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
@@ -583,46 +583,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(() => {
|
const filteredAndSortedHosts = useMemo(() => {
|
||||||
let filtered = hosts;
|
let filtered = hosts;
|
||||||
@@ -1419,48 +1379,15 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
{t("hosts.fileManagerBadge")}
|
{t("hosts.fileManagerBadge")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{host.enableDocker && (
|
||||||
{(() => {
|
|
||||||
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
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-xs px-1 py-0"
|
className="text-xs px-1 py-0"
|
||||||
>
|
>
|
||||||
<Activity className="h-2 w-2 mr-0.5" />
|
<Container className="h-2 w-2 mr-0.5" />
|
||||||
{t("hosts.statusMonitoring")}:{" "}
|
Docker
|
||||||
{monitoringStatus.statusInterval}
|
|
||||||
</Badge>
|
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1519,6 +1446,60 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</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>Open Docker</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -363,8 +363,63 @@ export function LeftSidebar({
|
|||||||
|
|
||||||
const filteredHosts = React.useMemo(() => {
|
const filteredHosts = React.useMemo(() => {
|
||||||
if (!debouncedSearch.trim()) return hosts;
|
if (!debouncedSearch.trim()) return hosts;
|
||||||
const q = debouncedSearch.trim().toLowerCase();
|
const searchQuery = debouncedSearch.trim().toLowerCase();
|
||||||
|
|
||||||
return hosts.filter((h) => {
|
return hosts.filter((h) => {
|
||||||
|
// Check for field-specific search patterns
|
||||||
|
const fieldMatches: Record<string, string> = {};
|
||||||
|
let remainingQuery = searchQuery;
|
||||||
|
|
||||||
|
// Extract field-specific queries (e.g., "tag:production", "user:root", "ip:192.168")
|
||||||
|
const fieldPattern = /(\w+):([^\s]+)/g;
|
||||||
|
let match;
|
||||||
|
while ((match = fieldPattern.exec(searchQuery)) !== null) {
|
||||||
|
const [fullMatch, field, value] = match;
|
||||||
|
fieldMatches[field] = value;
|
||||||
|
remainingQuery = remainingQuery.replace(fullMatch, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle field-specific searches
|
||||||
|
for (const [field, value] of Object.entries(fieldMatches)) {
|
||||||
|
switch (field) {
|
||||||
|
case "tag":
|
||||||
|
case "tags":
|
||||||
|
const tags = Array.isArray(h.tags) ? h.tags : [];
|
||||||
|
const hasMatchingTag = tags.some((tag) =>
|
||||||
|
tag.toLowerCase().includes(value)
|
||||||
|
);
|
||||||
|
if (!hasMatchingTag) return false;
|
||||||
|
break;
|
||||||
|
case "name":
|
||||||
|
if (!(h.name || "").toLowerCase().includes(value)) return false;
|
||||||
|
break;
|
||||||
|
case "user":
|
||||||
|
case "username":
|
||||||
|
if (!h.username.toLowerCase().includes(value)) return false;
|
||||||
|
break;
|
||||||
|
case "ip":
|
||||||
|
case "host":
|
||||||
|
if (!h.ip.toLowerCase().includes(value)) return false;
|
||||||
|
break;
|
||||||
|
case "port":
|
||||||
|
if (!String(h.port).includes(value)) return false;
|
||||||
|
break;
|
||||||
|
case "folder":
|
||||||
|
if (!(h.folder || "").toLowerCase().includes(value)) return false;
|
||||||
|
break;
|
||||||
|
case "auth":
|
||||||
|
case "authtype":
|
||||||
|
if (!h.authType.toLowerCase().includes(value)) return false;
|
||||||
|
break;
|
||||||
|
case "path":
|
||||||
|
if (!(h.defaultPath || "").toLowerCase().includes(value))
|
||||||
|
return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's remaining query text (not field-specific), search across all fields
|
||||||
|
if (remainingQuery) {
|
||||||
const searchableText = [
|
const searchableText = [
|
||||||
h.name || "",
|
h.name || "",
|
||||||
h.username,
|
h.username,
|
||||||
@@ -376,7 +431,10 @@ export function LeftSidebar({
|
|||||||
]
|
]
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return searchableText.includes(q);
|
if (!searchableText.includes(remainingQuery)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
}, [hosts, debouncedSearch]);
|
}, [hosts, debouncedSearch]);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
const [serverStatus, setServerStatus] = useState<
|
const [serverStatus, setServerStatus] = useState<
|
||||||
"online" | "offline" | "degraded"
|
"online" | "offline" | "degraded"
|
||||||
>("degraded");
|
>("degraded");
|
||||||
|
const [showTags, setShowTags] = useState<boolean>(() => {
|
||||||
|
const saved = localStorage.getItem("showHostTags");
|
||||||
|
return saved !== null ? saved === "true" : true;
|
||||||
|
});
|
||||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||||
const hasTags = tags.length > 0;
|
const hasTags = tags.length > 0;
|
||||||
|
|
||||||
@@ -54,6 +58,17 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||||
}, [host.id]);
|
}, [host.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleShowTagsChanged = () => {
|
||||||
|
const saved = localStorage.getItem("showHostTags");
|
||||||
|
setShowTags(saved !== null ? saved === "true" : true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("showHostTagsChanged", handleShowTagsChanged);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener("showHostTagsChanged", handleShowTagsChanged);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const statsConfig = useMemo(() => {
|
const statsConfig = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return host.statsConfig
|
return host.statsConfig
|
||||||
@@ -216,8 +231,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
|
{showTags && hasTags && (
|
||||||
{hasTags && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
{tags.map((tag: string) => (
|
{tags.map((tag: string) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/components/ui/tabs.tsx";
|
} from "@/components/ui/tabs.tsx";
|
||||||
import { Separator } from "@/components/ui/separator.tsx";
|
import { Separator } from "@/components/ui/separator.tsx";
|
||||||
import { Switch } from "@/components/ui/switch.tsx";
|
import { Switch } from "@/components/ui/switch.tsx";
|
||||||
import { User, Shield, AlertCircle } from "lucide-react";
|
import { User, Shield, AlertCircle, Palette } from "lucide-react";
|
||||||
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
|
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
|
||||||
import {
|
import {
|
||||||
getUserInfo,
|
getUserInfo,
|
||||||
@@ -107,6 +107,10 @@ export function UserProfile({
|
|||||||
useState<boolean>(
|
useState<boolean>(
|
||||||
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
||||||
);
|
);
|
||||||
|
const [showHostTags, setShowHostTags] = useState<boolean>(() => {
|
||||||
|
const saved = localStorage.getItem("showHostTags");
|
||||||
|
return saved !== null ? saved === "true" : true;
|
||||||
|
});
|
||||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -176,6 +180,12 @@ export function UserProfile({
|
|||||||
window.dispatchEvent(new Event("defaultSnippetFoldersCollapsedChanged"));
|
window.dispatchEvent(new Event("defaultSnippetFoldersCollapsedChanged"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShowHostTagsToggle = (enabled: boolean) => {
|
||||||
|
setShowHostTags(enabled);
|
||||||
|
localStorage.setItem("showHostTags", enabled.toString());
|
||||||
|
window.dispatchEvent(new Event("showHostTagsChanged"));
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDeleteLoading(true);
|
setDeleteLoading(true);
|
||||||
@@ -285,7 +295,14 @@ export function UserProfile({
|
|||||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||||
>
|
>
|
||||||
<User className="w-4 h-4" />
|
<User className="w-4 h-4" />
|
||||||
{t("nav.userProfile")}
|
{t("profile.account")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="appearance"
|
||||||
|
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||||
|
>
|
||||||
|
<Palette className="w-4 h-4" />
|
||||||
|
{t("profile.appearance")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
|
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
@@ -380,73 +397,6 @@ export function UserProfile({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-gray-300">
|
|
||||||
{t("common.language")}
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
{t("profile.selectPreferredLanguage")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-gray-300">
|
|
||||||
{t("profile.fileColorCoding")}
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
{t("profile.fileColorCodingDesc")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={fileColorCoding}
|
|
||||||
onCheckedChange={handleFileColorCodingToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-gray-300">
|
|
||||||
{t("profile.commandAutocomplete")}
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
{t("profile.commandAutocompleteDesc")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={commandAutocomplete}
|
|
||||||
onCheckedChange={handleCommandAutocompleteToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Label className="text-gray-300">
|
|
||||||
{t("profile.defaultSnippetFoldersCollapsed")}
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
|
||||||
{t("profile.defaultSnippetFoldersCollapsedDesc")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={defaultSnippetFoldersCollapsed}
|
|
||||||
onCheckedChange={
|
|
||||||
handleDefaultSnippetFoldersCollapsedToggle
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -471,6 +421,122 @@ export function UserProfile({
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="appearance" className="space-y-4">
|
||||||
|
{/* Language & Localization Section */}
|
||||||
|
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{t("profile.languageLocalization")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-300">
|
||||||
|
{t("common.language")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{t("profile.selectPreferredLanguage")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Manager Section */}
|
||||||
|
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{t("profile.fileManagerSettings")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-300">
|
||||||
|
{t("profile.fileColorCoding")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{t("profile.fileColorCodingDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={fileColorCoding}
|
||||||
|
onCheckedChange={handleFileColorCodingToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal Section */}
|
||||||
|
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{t("profile.terminalSettings")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-300">
|
||||||
|
{t("profile.commandAutocomplete")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{t("profile.commandAutocompleteDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={commandAutocomplete}
|
||||||
|
onCheckedChange={handleCommandAutocompleteToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Host & Sidebar Section */}
|
||||||
|
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{t("profile.hostSidebarSettings")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-300">
|
||||||
|
{t("profile.showHostTags")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{t("profile.showHostTagsDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={showHostTags}
|
||||||
|
onCheckedChange={handleShowHostTagsToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Snippets Section */}
|
||||||
|
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">
|
||||||
|
{t("profile.snippetsSettings")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-gray-300">
|
||||||
|
{t("profile.defaultSnippetFoldersCollapsed")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{t("profile.defaultSnippetFoldersCollapsedDesc")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={defaultSnippetFoldersCollapsed}
|
||||||
|
onCheckedChange={
|
||||||
|
handleDefaultSnippetFoldersCollapsedToggle
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="security" className="space-y-4">
|
<TabsContent value="security" className="space-y-4">
|
||||||
<TOTPSetup
|
<TOTPSetup
|
||||||
isEnabled={userInfo.totp_enabled}
|
isEnabled={userInfo.totp_enabled}
|
||||||
|
|||||||
Reference in New Issue
Block a user