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

@@ -2,7 +2,7 @@
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -51,7 +51,10 @@ export default function ConsolePage() {
// Dynamic import noVNC // Dynamic import noVNC
// @ts-expect-error noVNC doesn't have TS declarations // @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 wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const params = new URLSearchParams({ const params = new URLSearchParams({

View File

@@ -99,7 +99,7 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <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')} <Terminal className="mr-2 h-4 w-4" /> {t('common.console', 'Console')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />

View File

@@ -1,40 +1,304 @@
import { useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useTranslation } from 'react-i18next'; 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 { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; 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 { 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() { export default function NodesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: servers = [], isLoading } = useServers(); const { data: servers = [], isLoading } = useServers();
const { data: vms = [] } = useVirtualMachines(); 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) { if (isLoading) {
return <div className="flex items-center justify-center py-20 text-muted-foreground">{t('common.loading')}</div>; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t('nav.nodes')}</h1> <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"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{servers.map(srv => { {servers.map(srv => {
const srvVMs = vms.filter(v => v.server_id === srv.id && v.type === 'qemu'); 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'); const srvCTs = vms.filter(v => v.server_id === srv.id && v.type === 'lxc');
return ( return (
<Link key={srv.id} to={`/nodes/${srv.id}`}> <Card key={srv.id} className="relative group">
<Card className="transition-colors hover:border-blue-500/50 h-full">
<CardContent className="p-5 space-y-4"> <CardContent className="p-5 space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-3"> <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"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<Server className="h-5 w-5" /> <Server className="h-5 w-5" />
</div> </div>
<div> <div className="min-w-0">
<p className="font-semibold">{srv.name}</p> <p className="font-semibold truncate">{srv.name}</p>
<p className="text-xs text-muted-foreground font-mono">{srv.ip_address}</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="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-1.5">
<Container className="h-3.5 w-3.5" />
<span>{srvCTs.length} LXC</span>
</div> </div>
</div> </div>
<Badge variant={srv.is_online ? 'default' : 'destructive'}> <Badge variant={srv.is_online ? 'default' : 'destructive'}>
@@ -42,23 +306,11 @@ export default function NodesPage() {
</Badge> </Badge>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Monitor className="h-3.5 w-3.5" />
<span>{srvVMs.length} VM</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Container className="h-3.5 w-3.5" />
<span>{srvCTs.length} LXC</span>
</div>
</div>
{srv.description && ( {srv.description && (
<p className="text-xs text-muted-foreground truncate">{srv.description}</p> <p className="text-xs text-muted-foreground truncate">{srv.description}</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</Link>
); );
})} })}
</div> </div>
@@ -66,9 +318,31 @@ export default function NodesPage() {
{servers.length === 0 && ( {servers.length === 0 && (
<div className="text-center py-16 text-muted-foreground"> <div className="text-center py-16 text-muted-foreground">
<Server className="mx-auto h-12 w-12 mb-3 opacity-50" /> <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> </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> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -50,11 +50,12 @@ function ProfileTab() {
const [confirmPwd, setConfirmPwd] = useState(''); const [confirmPwd, setConfirmPwd] = useState('');
// Init from profile // Init from profile
const inited = displayName || email; useEffect(() => {
if (profile && !inited) { if (profile) {
setDisplayName(profile.full_name || ''); setDisplayName(profile.full_name || '');
setEmail(profile.email || ''); setEmail(profile.email || '');
} }
}, [profile]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -91,10 +92,12 @@ function PanelTab() {
const [panelName, setPanelName] = useState(''); const [panelName, setPanelName] = useState('');
const [lang, setLang] = useState(''); const [lang, setLang] = useState('');
if (settings && !panelName) { useEffect(() => {
if (settings) {
setPanelName(settings.panel_name || ''); setPanelName(settings.panel_name || '');
setLang(settings.language || 'ru'); setLang(settings.language || 'ru');
} }
}, [settings]);
return ( return (
<Card> <Card>
@@ -122,11 +125,13 @@ function SecurityTab() {
const [lockoutDuration, setLockoutDuration] = useState(''); const [lockoutDuration, setLockoutDuration] = useState('');
const [sessionTimeout, setSessionTimeout] = useState(''); const [sessionTimeout, setSessionTimeout] = useState('');
if (security && !maxAttempts) { useEffect(() => {
if (security) {
setMaxAttempts(String(security.max_login_attempts ?? 5)); setMaxAttempts(String(security.max_login_attempts ?? 5));
setLockoutDuration(String(security.lockout_duration ?? 300)); setLockoutDuration(String(security.lockout_duration ?? 300));
setSessionTimeout(String(security.session_timeout ?? 3600)); setSessionTimeout(String(security.session_timeout ?? 3600));
} }
}, [security]);
return ( return (
<Card> <Card>
@@ -158,16 +163,21 @@ function NotificationsTab() {
const [tgToken, setTgToken] = useState(''); const [tgToken, setTgToken] = useState('');
const [tgChatId, setTgChatId] = useState(''); const [tgChatId, setTgChatId] = useState('');
if (channels?.smtp && !smtpHost) { useEffect(() => {
if (channels?.smtp) {
setSmtpHost(String(channels.smtp.host ?? '')); setSmtpHost(String(channels.smtp.host ?? ''));
setSmtpPort(String(channels.smtp.port ?? 587)); setSmtpPort(String(channels.smtp.port ?? 587));
setSmtpUser(String(channels.smtp.username ?? '')); setSmtpUser(String(channels.smtp.username ?? ''));
setSmtpFrom(String(channels.smtp.from_email ?? '')); setSmtpFrom(String(channels.smtp.from_email ?? ''));
} }
if (channels?.telegram && !tgToken) { }, [channels?.smtp]);
useEffect(() => {
if (channels?.telegram) {
setTgToken(String(channels.telegram.telegram_bot_token ?? channels.telegram.bot_token ?? '')); setTgToken(String(channels.telegram.telegram_bot_token ?? channels.telegram.bot_token ?? ''));
setTgChatId(String(channels.telegram.chat_id ?? '')); setTgChatId(String(channels.telegram.chat_id ?? ''));
} }
}, [channels?.telegram]);
return ( return (
<div className="space-y-4"> <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 { useTranslation } from 'react-i18next';
import { Plus, Search, Trash2, Users, Server, Pencil } from 'lucide-react'; import { Plus, Search, Trash2, Users, Server, Pencil } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogClose, DialogFooter } from '@/components/ui/dialog'; 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() { export default function WorkspacesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -98,25 +101,147 @@ export default function WorkspacesPage() {
)} )}
</div> </div>
{/* Edit Workspace Dialog */} {editId && (
<Dialog open={editOpen} onOpenChange={setEditOpen}> <EditWorkspaceDialog
<DialogContent> open={editOpen}
<DialogHeader><DialogTitle>{t('workspaces.edit')}</DialogTitle></DialogHeader> onOpenChange={setEditOpen}
<div className="space-y-3"> workspaceId={editId}
<div><Label>{t('common.name')}</Label><Input value={editName} onChange={e => setEditName(e.target.value)} className="mt-1" /></div> initialName={editName}
<div><Label>{t('ipam.description')}</Label><Input value={editDescription} onChange={e => setEditDescription(e.target.value)} className="mt-1" /></div> initialDescription={editDescription}
</div> onSave={(name, description) => {
<DialogFooter> updateWorkspace.mutate({ id: editId, name, description });
<DialogClose render={<Button variant="outline" />}>{t('common.cancel')}</DialogClose>
<Button onClick={() => {
if (editId) {
updateWorkspace.mutate({ id: editId, name: editName, description: editDescription });
}
setEditOpen(false); setEditOpen(false);
}}>{t('common.save')}</Button> }}
</DialogFooter> />
</DialogContent> )}
</Dialog>
</div> </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>
);
}

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import type { ProxmoxServer, ProxmoxNode } from '@/types'; import type { ProxmoxServer, ProxmoxNode, ProxmoxServerCreate } from '@/types';
export const nodeKeys = { export const nodeKeys = {
servers: ['servers'] as const, servers: ['servers'] as const,
@@ -70,3 +70,35 @@ export function useStorages(serverId: number, node: string) {
enabled: serverId > 0 && !!node, enabled: serverId > 0 && !!node,
}); });
} }
export function useCreateServer() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: ProxmoxServerCreate) =>
apiClient.post<ProxmoxServer>('/proxmox/api/servers', data),
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.servers }),
});
}
export function useUpdateServer() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: number } & Partial<ProxmoxServerCreate>) =>
apiClient.put<ProxmoxServer>(`/proxmox/api/servers/${id}`, data),
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.servers }),
});
}
export function useDeleteServer() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => apiClient.delete(`/proxmox/api/servers/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: nodeKeys.servers }),
});
}
export function useTestServer() {
return useMutation({
mutationFn: (id: number) => apiClient.post<{ success: boolean; message?: string }>(`/proxmox/api/servers/${id}/test`, {}),
});
}

View File

@@ -88,7 +88,7 @@ function SidebarContent() {
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Logo */} {/* Logo */}
<div className="flex h-14 items-center gap-2 px-4"> <div className="flex h-14 items-center gap-2 px-4">
<img src="/favicon.svg" alt="PVEmanager" className="h-8 w-8" /> <img src="/logo.png" alt="PVEmanager" className="h-8 w-auto" />
<span className="text-base font-semibold tracking-tight">PVEmanager</span> <span className="text-base font-semibold tracking-tight">PVEmanager</span>
</div> </div>

View File

@@ -117,7 +117,20 @@
"disk": "Disk", "disk": "Disk",
"uptime": "Uptime", "uptime": "Uptime",
"cluster_nodes": "Cluster Nodes", "cluster_nodes": "Cluster Nodes",
"vm_list": "Virtual Machines on Server" "vm_list": "Virtual Machines on Server",
"no_servers": "No servers configured",
"add_server": "Add Server",
"edit_server": "Edit Server",
"test_connection": "Test Connection",
"hostname": "Hostname / IP",
"port": "Port",
"api_user": "API User",
"api_token_name": "API Token Name",
"api_token_value": "API Token Value",
"use_password": "Use password auth",
"verify_ssl": "Verify SSL",
"connection_ok": "Connection successful",
"connection_failed": "Connection failed"
}, },
"templates": { "templates": {
"title": "OS Templates", "title": "OS Templates",
@@ -128,23 +141,40 @@
"importing": "Importing...", "importing": "Importing...",
"no_templates": "No templates found", "no_templates": "No templates found",
"os": "Operating System", "os": "Operating System",
"version": "Version" "version": "Version",
"imported": "Imported",
"select_server": "Select server",
"import_btn": "Import Templates"
}, },
"backups": { "backups": {
"title": "Backups", "title": "Backups",
"files": "Backup Files", "files": "Backup Files",
"jobs": "Scheduled Jobs", "jobs": "Scheduled Jobs",
"scheduled_jobs": "Scheduled Jobs",
"storage": "Storage", "storage": "Storage",
"server": "Server", "server": "Server",
"size": "Size", "size": "Size",
"date": "Date", "date": "Date",
"format": "Format",
"restore": "Restore", "restore": "Restore",
"download": "Download", "download": "Download",
"schedule": "Schedule", "schedule": "Schedule",
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled", "disabled": "Disabled",
"run_now": "Run Now", "run_now": "Run Now",
"no_backups": "No backups found" "no_backups": "No backups found",
"no_jobs": "No scheduled jobs",
"volume": "Volume",
"notes": "Notes",
"vmids": "VMIDs",
"storage_col": "Storage",
"select_server": "Select server",
"select_storage": "Select storage",
"node": "Node",
"select_server_first": "Select a server to view backups",
"disable": "Disable",
"enable": "Enable",
"job_started": "Backup job started"
}, },
"ipam": { "ipam": {
"dashboard": "IPAM Dashboard", "dashboard": "IPAM Dashboard",
@@ -232,7 +262,11 @@
"users": "Users", "users": "Users",
"created": "Created", "created": "Created",
"all": "All workspaces", "all": "All workspaces",
"select": "Select workspace" "select": "Select workspace",
"assign_servers": "Assign Servers",
"assign_users": "Assign Users",
"no_servers": "No servers available",
"no_users": "No users available"
}, },
"tasks": { "tasks": {
"clear_completed": "Clear Completed", "clear_completed": "Clear Completed",

View File

@@ -117,7 +117,20 @@
"disk": "Диск", "disk": "Диск",
"uptime": "Аптайм", "uptime": "Аптайм",
"cluster_nodes": "Узлы кластера", "cluster_nodes": "Узлы кластера",
"vm_list": "Виртуальные машины на сервере" "vm_list": "Виртуальные машины на сервере",
"no_servers": "Серверы не настроены",
"add_server": "Добавить сервер",
"edit_server": "Редактировать сервер",
"test_connection": "Проверить соединение",
"hostname": "Хост / IP",
"port": "Порт",
"api_user": "API пользователь",
"api_token_name": "Имя API токена",
"api_token_value": "Значение API токена",
"use_password": "Использовать аутентификацию по паролю",
"verify_ssl": "Проверять SSL",
"connection_ok": "Соединение успешно",
"connection_failed": "Ошибка соединения"
}, },
"templates": { "templates": {
"title": "Шаблоны ОС", "title": "Шаблоны ОС",
@@ -128,23 +141,40 @@
"importing": "Импорт...", "importing": "Импорт...",
"no_templates": "Шаблоны не найдены", "no_templates": "Шаблоны не найдены",
"os": "Операционная система", "os": "Операционная система",
"version": "Версия" "version": "Версия",
"imported": "Импортировано",
"select_server": "Выберите сервер",
"import_btn": "Импортировать шаблоны"
}, },
"backups": { "backups": {
"title": "Бэкапы", "title": "Бэкапы",
"files": "Файлы бэкапов", "files": "Файлы бэкапов",
"jobs": "Задания по расписанию", "jobs": "Задания по расписанию",
"scheduled_jobs": "Задания по расписанию",
"storage": "Хранилище", "storage": "Хранилище",
"server": "Сервер", "server": "Сервер",
"size": "Размер", "size": "Размер",
"date": "Дата", "date": "Дата",
"format": "Формат",
"restore": "Восстановить", "restore": "Восстановить",
"download": "Скачать", "download": "Скачать",
"schedule": "Расписание", "schedule": "Расписание",
"enabled": "Включено", "enabled": "Включено",
"disabled": "Выключено", "disabled": "Выключено",
"run_now": "Запустить сейчас", "run_now": "Запустить сейчас",
"no_backups": "Бэкапы не найдены" "no_backups": "Бэкапы не найдены",
"no_jobs": "Нет заданий по расписанию",
"volume": "Том",
"notes": "Заметки",
"vmids": "VMID",
"storage_col": "Хранилище",
"select_server": "Выберите сервер",
"select_storage": "Выберите хранилище",
"node": "Узел",
"select_server_first": "Выберите сервер для просмотра бэкапов",
"disable": "Отключить",
"enable": "Включить",
"job_started": "Задание бэкапа запущено"
}, },
"ipam": { "ipam": {
"dashboard": "IPAM Дашборд", "dashboard": "IPAM Дашборд",
@@ -232,7 +262,11 @@
"users": "Пользователи", "users": "Пользователи",
"created": "Создано", "created": "Создано",
"all": "Все рабочие области", "all": "Все рабочие области",
"select": "Выбрать рабочую область" "select": "Выбрать рабочую область",
"assign_servers": "Назначить серверы",
"assign_users": "Назначить пользователей",
"no_servers": "Серверы недоступны",
"no_users": "Пользователи недоступны"
}, },
"tasks": { "tasks": {
"clear_completed": "Очистить завершённые", "clear_completed": "Очистить завершённые",