feat: improve nodes, workspaces, settings pages; add favicon and logo; update locales

This commit is contained in:
markmorado
2026-04-29 16:38:32 +02:00
parent 1d08c815a7
commit 297082dc72
12 changed files with 600 additions and 88 deletions

View File

@@ -51,7 +51,10 @@ export default function ConsolePage() {
// Dynamic import noVNC
// @ts-expect-error noVNC doesn't have TS declarations
const { default: RFB } = await import('@novnc/novnc/lib/rfb');
const rfbModule = await import('@novnc/novnc/lib/rfb');
// Handle both ESM default export and CJS module.exports
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const RFB = (rfbModule.default ?? rfbModule) as any;
const wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const params = new URLSearchParams({

View File

@@ -99,7 +99,7 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem render={<Link to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`} />}>
<DropdownMenuItem render={<Link to={`/console/${vm.server_id}/${vm.vmid}?node=${vm.node}&type=${vm.type}`} target="_blank" rel="noopener noreferrer" />}>
<Terminal className="mr-2 h-4 w-4" /> {t('common.console', 'Console')}
</DropdownMenuItem>
<DropdownMenuSeparator />

View File

@@ -1,64 +1,316 @@
import { useState } from 'react';
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Server, Monitor, Container } from 'lucide-react';
import { Server, Monitor, Container, Plus, Pencil, Trash2, Wifi, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useServers } from '@/hooks/use-nodes';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog';
import { useServers, useCreateServer, useUpdateServer, useDeleteServer, useTestServer } from '@/hooks/use-nodes';
import { useVirtualMachines } from '@/hooks/use-instances';
import type { ProxmoxServerCreate } from '@/types';
import { toast } from 'sonner';
interface ServerFormData {
name: string;
hostname: string;
ip_address: string;
port: string;
api_user: string;
api_token_name: string;
api_token_value: string;
use_password: boolean;
password: string;
verify_ssl: boolean;
description: string;
}
const emptyForm: ServerFormData = {
name: '',
hostname: '',
ip_address: '',
port: '8006',
api_user: 'root@pam',
api_token_name: '',
api_token_value: '',
use_password: false,
password: '',
verify_ssl: false,
description: '',
};
function ServerFormDialog({
open,
onOpenChange,
title,
initialData,
onSubmit,
isPending,
serverId,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
title: string;
initialData: ServerFormData;
onSubmit: (data: ProxmoxServerCreate) => void;
isPending: boolean;
serverId?: number;
}) {
const { t } = useTranslation();
const [form, setForm] = useState<ServerFormData>(initialData);
const testServer = useTestServer();
// Sync initialData when dialog opens
const handleOpen = (v: boolean) => {
if (v) setForm(initialData);
onOpenChange(v);
};
const set = (field: keyof ServerFormData, value: string | boolean) =>
setForm(prev => ({ ...prev, [field]: value }));
const handleSubmit = () => {
const payload: ProxmoxServerCreate = {
name: form.name,
hostname: form.hostname || form.ip_address,
ip_address: form.ip_address,
port: form.port ? Number(form.port) : 8006,
api_user: form.api_user || 'root@pam',
verify_ssl: form.verify_ssl,
description: form.description || undefined,
};
if (form.use_password) {
payload.use_password = true;
payload.password = form.password;
} else {
payload.api_token_name = form.api_token_name;
payload.api_token_value = form.api_token_value;
}
onSubmit(payload);
};
const handleTest = () => {
if (!serverId) return;
testServer.mutate(serverId, {
onSuccess: (res) => {
if (res.success) toast.success(t('nodes.connection_ok'));
else toast.error(t('nodes.connection_failed') + (res.message ? `: ${res.message}` : ''));
},
onError: (err) => toast.error(err.message),
});
};
return (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<Label>{t('common.name')}</Label>
<Input value={form.name} onChange={e => set('name', e.target.value)} className="mt-1" placeholder="My Proxmox Server" />
</div>
<div>
<Label>{t('nodes.hostname')}</Label>
<Input value={form.ip_address} onChange={e => { set('ip_address', e.target.value); set('hostname', e.target.value); }} className="mt-1" placeholder="192.168.1.100" />
</div>
<div>
<Label>{t('nodes.port')}</Label>
<Input type="number" value={form.port} onChange={e => set('port', e.target.value)} className="mt-1" placeholder="8006" />
</div>
<div>
<Label>{t('nodes.api_user')}</Label>
<Input value={form.api_user} onChange={e => set('api_user', e.target.value)} className="mt-1" placeholder="root@pam" />
</div>
</div>
<div className="flex items-center gap-2">
<input
id="use_password"
type="checkbox"
checked={form.use_password}
onChange={e => set('use_password', e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="use_password" className="cursor-pointer">{t('nodes.use_password')}</Label>
</div>
{form.use_password ? (
<div>
<Label>{t('wizard.password')}</Label>
<Input type="password" value={form.password} onChange={e => set('password', e.target.value)} className="mt-1" />
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<Label>{t('nodes.api_token_name')}</Label>
<Input value={form.api_token_name} onChange={e => set('api_token_name', e.target.value)} className="mt-1" placeholder="mytoken" />
</div>
<div>
<Label>{t('nodes.api_token_value')}</Label>
<Input value={form.api_token_value} onChange={e => set('api_token_value', e.target.value)} className="mt-1" placeholder="xxxxxxxx-xxxx-..." />
</div>
</div>
)}
<div className="flex items-center gap-2">
<input
id="verify_ssl"
type="checkbox"
checked={form.verify_ssl}
onChange={e => set('verify_ssl', e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="verify_ssl" className="cursor-pointer">{t('nodes.verify_ssl')}</Label>
</div>
<div>
<Label>{t('ipam.description')}</Label>
<Input value={form.description} onChange={e => set('description', e.target.value)} className="mt-1" />
</div>
</div>
<DialogFooter className="gap-2">
{serverId && (
<Button variant="outline" size="sm" onClick={handleTest} disabled={testServer.isPending}>
{testServer.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wifi className="h-4 w-4 mr-1" />}
{t('nodes.test_connection')}
</Button>
)}
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={handleSubmit} disabled={isPending || !form.name || !form.ip_address}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function NodesPage() {
const { t } = useTranslation();
const { data: servers = [], isLoading } = useServers();
const { data: vms = [] } = useVirtualMachines();
const createServer = useCreateServer();
const updateServer = useUpdateServer();
const deleteServer = useDeleteServer();
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editServerId, setEditServerId] = useState<number | null>(null);
const [editInitial, setEditInitial] = useState<ServerFormData>(emptyForm);
if (isLoading) {
return <div className="flex items-center justify-center py-20 text-muted-foreground">{t('common.loading')}</div>;
}
const handleCreate = (data: ProxmoxServerCreate) => {
createServer.mutate(data, {
onSuccess: () => { toast.success(t('common.save')); setCreateOpen(false); },
onError: (err) => toast.error(err.message),
});
};
const handleEdit = (data: ProxmoxServerCreate) => {
if (!editServerId) return;
updateServer.mutate({ id: editServerId, ...data }, {
onSuccess: () => { toast.success(t('common.save')); setEditOpen(false); },
onError: (err) => toast.error(err.message),
});
};
const handleDelete = (id: number, name: string) => {
if (!confirm(`${t('common.confirm_delete')} "${name}"?`)) return;
deleteServer.mutate(id, {
onError: (err) => toast.error(err.message),
});
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t('nav.nodes')}</h1>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.nodes')}</h1>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />{t('nodes.add_server')}
</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{servers.map(srv => {
const srvVMs = vms.filter(v => v.server_id === srv.id && v.type === 'qemu');
const srvCTs = vms.filter(v => v.server_id === srv.id && v.type === 'lxc');
return (
<Link key={srv.id} to={`/nodes/${srv.id}`}>
<Card className="transition-colors hover:border-blue-500/50 h-full">
<CardContent className="p-5 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Server className="h-5 w-5" />
</div>
<div>
<p className="font-semibold">{srv.name}</p>
<p className="text-xs text-muted-foreground font-mono">{srv.ip_address}</p>
</div>
<Card key={srv.id} className="relative group">
<CardContent className="p-5 space-y-4">
<div className="flex items-start justify-between">
<Link to={`/nodes/${srv.id}`} className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<Server className="h-5 w-5" />
</div>
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
{srv.is_online ? t('common.online') : t('common.offline')}
</Badge>
<div className="min-w-0">
<p className="font-semibold truncate">{srv.name}</p>
<p className="text-xs text-muted-foreground font-mono truncate">{srv.ip_address}</p>
</div>
</Link>
<div className="flex items-center gap-1 ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditServerId(srv.id);
setEditInitial({
name: srv.name,
hostname: srv.hostname || srv.ip_address,
ip_address: srv.ip_address,
port: String(srv.port || 8006),
api_user: srv.api_user || 'root@pam',
api_token_name: '',
api_token_value: '',
use_password: false,
password: '',
verify_ssl: srv.verify_ssl || false,
description: srv.description || '',
});
setEditOpen(true);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => handleDelete(srv.id, srv.name)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Monitor className="h-3.5 w-3.5" />
<span>{srvVMs.length} VM</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<div className="flex items-center gap-1.5">
<Container className="h-3.5 w-3.5" />
<span>{srvCTs.length} LXC</span>
</div>
</div>
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
{srv.is_online ? t('common.online') : t('common.offline')}
</Badge>
</div>
{srv.description && (
<p className="text-xs text-muted-foreground truncate">{srv.description}</p>
)}
</CardContent>
</Card>
</Link>
{srv.description && (
<p className="text-xs text-muted-foreground truncate">{srv.description}</p>
)}
</CardContent>
</Card>
);
})}
</div>
@@ -66,9 +318,31 @@ export default function NodesPage() {
{servers.length === 0 && (
<div className="text-center py-16 text-muted-foreground">
<Server className="mx-auto h-12 w-12 mb-3 opacity-50" />
<p>{t('nodes.no_servers')}</p>
<p className="mb-4">{t('nodes.no_servers')}</p>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />{t('nodes.add_server')}
</Button>
</div>
)}
<ServerFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
title={t('nodes.add_server')}
initialData={emptyForm}
onSubmit={handleCreate}
isPending={createServer.isPending}
/>
<ServerFormDialog
open={editOpen}
onOpenChange={setEditOpen}
title={t('nodes.edit_server')}
initialData={editInitial}
onSubmit={handleEdit}
isPending={updateServer.isPending}
serverId={editServerId ?? undefined}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -50,11 +50,12 @@ function ProfileTab() {
const [confirmPwd, setConfirmPwd] = useState('');
// Init from profile
const inited = displayName || email;
if (profile && !inited) {
setDisplayName(profile.full_name || '');
setEmail(profile.email || '');
}
useEffect(() => {
if (profile) {
setDisplayName(profile.full_name || '');
setEmail(profile.email || '');
}
}, [profile]);
return (
<div className="space-y-4">
@@ -91,10 +92,12 @@ function PanelTab() {
const [panelName, setPanelName] = useState('');
const [lang, setLang] = useState('');
if (settings && !panelName) {
setPanelName(settings.panel_name || '');
setLang(settings.language || 'ru');
}
useEffect(() => {
if (settings) {
setPanelName(settings.panel_name || '');
setLang(settings.language || 'ru');
}
}, [settings]);
return (
<Card>
@@ -122,11 +125,13 @@ function SecurityTab() {
const [lockoutDuration, setLockoutDuration] = useState('');
const [sessionTimeout, setSessionTimeout] = useState('');
if (security && !maxAttempts) {
setMaxAttempts(String(security.max_login_attempts ?? 5));
setLockoutDuration(String(security.lockout_duration ?? 300));
setSessionTimeout(String(security.session_timeout ?? 3600));
}
useEffect(() => {
if (security) {
setMaxAttempts(String(security.max_login_attempts ?? 5));
setLockoutDuration(String(security.lockout_duration ?? 300));
setSessionTimeout(String(security.session_timeout ?? 3600));
}
}, [security]);
return (
<Card>
@@ -158,16 +163,21 @@ function NotificationsTab() {
const [tgToken, setTgToken] = useState('');
const [tgChatId, setTgChatId] = useState('');
if (channels?.smtp && !smtpHost) {
setSmtpHost(String(channels.smtp.host ?? ''));
setSmtpPort(String(channels.smtp.port ?? 587));
setSmtpUser(String(channels.smtp.username ?? ''));
setSmtpFrom(String(channels.smtp.from_email ?? ''));
}
if (channels?.telegram && !tgToken) {
setTgToken(String(channels.telegram.telegram_bot_token ?? channels.telegram.bot_token ?? ''));
setTgChatId(String(channels.telegram.chat_id ?? ''));
}
useEffect(() => {
if (channels?.smtp) {
setSmtpHost(String(channels.smtp.host ?? ''));
setSmtpPort(String(channels.smtp.port ?? 587));
setSmtpUser(String(channels.smtp.username ?? ''));
setSmtpFrom(String(channels.smtp.from_email ?? ''));
}
}, [channels?.smtp]);
useEffect(() => {
if (channels?.telegram) {
setTgToken(String(channels.telegram.telegram_bot_token ?? channels.telegram.bot_token ?? ''));
setTgChatId(String(channels.telegram.chat_id ?? ''));
}
}, [channels?.telegram]);
return (
<div className="space-y-4">

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Search, Trash2, Users, Server, Pencil } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -7,7 +7,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, DialogFooter } from '@/components/ui/dialog';
import { useWorkspaces, useCreateWorkspace, useUpdateWorkspace, useDeleteWorkspace } from '@/hooks/use-workspaces';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useWorkspaces, useCreateWorkspace, useUpdateWorkspace, useDeleteWorkspace, useWorkspace, useUpdateWorkspaceServers, useUpdateWorkspaceUsers } from '@/hooks/use-workspaces';
import { useServers } from '@/hooks/use-nodes';
import { useUsers } from '@/hooks/use-users';
export default function WorkspacesPage() {
const { t } = useTranslation();
@@ -98,25 +101,147 @@ export default function WorkspacesPage() {
)}
</div>
{/* Edit Workspace Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{t('workspaces.edit')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div><Label>{t('common.name')}</Label><Input value={editName} onChange={e => setEditName(e.target.value)} className="mt-1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={editDescription} onChange={e => setEditDescription(e.target.value)} className="mt-1" /></div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={() => {
if (editId) {
updateWorkspace.mutate({ id: editId, name: editName, description: editDescription });
}
setEditOpen(false);
}}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{editId && (
<EditWorkspaceDialog
open={editOpen}
onOpenChange={setEditOpen}
workspaceId={editId}
initialName={editName}
initialDescription={editDescription}
onSave={(name, description) => {
updateWorkspace.mutate({ id: editId, name, description });
setEditOpen(false);
}}
/>
)}
</div>
);
}
function EditWorkspaceDialog({
open, onOpenChange, workspaceId, initialName, initialDescription, onSave,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
workspaceId: number;
initialName: string;
initialDescription: string;
onSave: (name: string, description: string) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const { data: workspace } = useWorkspace(workspaceId);
const { data: allServers = [] } = useServers();
const { data: allUsers = [] } = useUsers();
const updateServers = useUpdateWorkspaceServers();
const updateUsers = useUpdateWorkspaceUsers();
// Track selected server/user IDs
const [selectedServerIds, setSelectedServerIds] = useState<number[]>([]);
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
// Populate selections from loaded workspace
useEffect(() => {
if (workspace) {
setSelectedServerIds(workspace.servers?.map((s: any) => s.id) ?? []);
setSelectedUserIds(workspace.users?.map((u: any) => u.id) ?? []);
}
}, [workspace?.id]);
const handleOpen = (v: boolean) => {
if (v) {
setName(initialName);
setDescription(initialDescription);
}
onOpenChange(v);
};
const toggleServer = (id: number) =>
setSelectedServerIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
const toggleUser = (id: number) =>
setSelectedUserIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
const handleSave = () => {
onSave(name, description);
updateServers.mutate({ id: workspaceId, ids: selectedServerIds });
updateUsers.mutate({ id: workspaceId, ids: selectedUserIds });
};
return (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{t('workspaces.edit')}</DialogTitle></DialogHeader>
<Tabs defaultValue="info">
<TabsList className="w-full">
<TabsTrigger value="info" className="flex-1">{t('common.name')}</TabsTrigger>
<TabsTrigger value="servers" className="flex-1">{t('workspaces.assign_servers')}</TabsTrigger>
<TabsTrigger value="users" className="flex-1">{t('workspaces.assign_users')}</TabsTrigger>
</TabsList>
<TabsContent value="info" className="space-y-3 mt-3">
<div><Label>{t('common.name')}</Label><Input value={name} onChange={e => setName(e.target.value)} className="mt-1" /></div>
<div><Label>{t('ipam.description')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} className="mt-1" /></div>
</TabsContent>
<TabsContent value="servers" className="mt-3">
{allServers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">{t('workspaces.no_servers')}</p>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
{allServers.map(srv => (
<label key={srv.id} className="flex items-center gap-3 rounded-md border p-2.5 cursor-pointer hover:bg-accent">
<input
type="checkbox"
checked={selectedServerIds.includes(srv.id)}
onChange={() => toggleServer(srv.id)}
className="h-4 w-4"
/>
<div className="min-w-0">
<p className="text-sm font-medium">{srv.name}</p>
<p className="text-xs text-muted-foreground font-mono">{srv.ip_address}</p>
</div>
<Badge variant={srv.is_online ? 'default' : 'destructive'} className="ml-auto shrink-0 text-xs">
{srv.is_online ? t('common.online') : t('common.offline')}
</Badge>
</label>
))}
</div>
)}
</TabsContent>
<TabsContent value="users" className="mt-3">
{allUsers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">{t('workspaces.no_users')}</p>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
{allUsers.map((u: any) => (
<label key={u.id} className="flex items-center gap-3 rounded-md border p-2.5 cursor-pointer hover:bg-accent">
<input
type="checkbox"
checked={selectedUserIds.includes(u.id)}
onChange={() => toggleUser(u.id)}
className="h-4 w-4"
/>
<div className="min-w-0">
<p className="text-sm font-medium">{u.username}</p>
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
</div>
{u.is_admin && <Badge variant="outline" className="ml-auto shrink-0 text-xs">Admin</Badge>}
</label>
))}
</div>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={handleSave}>{t('common.save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}