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">
<head>
<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" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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
// @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>
);
}

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 type { ProxmoxServer, ProxmoxNode } from '@/types';
import type { ProxmoxServer, ProxmoxNode, ProxmoxServerCreate } from '@/types';
export const nodeKeys = {
servers: ['servers'] as const,
@@ -70,3 +70,35 @@ export function useStorages(serverId: number, node: string) {
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">
{/* Logo */}
<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>
</div>

View File

@@ -117,7 +117,20 @@
"disk": "Disk",
"uptime": "Uptime",
"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": {
"title": "OS Templates",
@@ -128,23 +141,40 @@
"importing": "Importing...",
"no_templates": "No templates found",
"os": "Operating System",
"version": "Version"
"version": "Version",
"imported": "Imported",
"select_server": "Select server",
"import_btn": "Import Templates"
},
"backups": {
"title": "Backups",
"files": "Backup Files",
"jobs": "Scheduled Jobs",
"scheduled_jobs": "Scheduled Jobs",
"storage": "Storage",
"server": "Server",
"size": "Size",
"date": "Date",
"format": "Format",
"restore": "Restore",
"download": "Download",
"schedule": "Schedule",
"enabled": "Enabled",
"disabled": "Disabled",
"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": {
"dashboard": "IPAM Dashboard",
@@ -232,7 +262,11 @@
"users": "Users",
"created": "Created",
"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": {
"clear_completed": "Clear Completed",

View File

@@ -117,7 +117,20 @@
"disk": "Диск",
"uptime": "Аптайм",
"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": {
"title": "Шаблоны ОС",
@@ -128,23 +141,40 @@
"importing": "Импорт...",
"no_templates": "Шаблоны не найдены",
"os": "Операционная система",
"version": "Версия"
"version": "Версия",
"imported": "Импортировано",
"select_server": "Выберите сервер",
"import_btn": "Импортировать шаблоны"
},
"backups": {
"title": "Бэкапы",
"files": "Файлы бэкапов",
"jobs": "Задания по расписанию",
"scheduled_jobs": "Задания по расписанию",
"storage": "Хранилище",
"server": "Сервер",
"size": "Размер",
"date": "Дата",
"format": "Формат",
"restore": "Восстановить",
"download": "Скачать",
"schedule": "Расписание",
"enabled": "Включено",
"disabled": "Выключено",
"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": {
"dashboard": "IPAM Дашборд",
@@ -232,7 +262,11 @@
"users": "Пользователи",
"created": "Создано",
"all": "Все рабочие области",
"select": "Выбрать рабочую область"
"select": "Выбрать рабочую область",
"assign_servers": "Назначить серверы",
"assign_users": "Назначить пользователей",
"no_servers": "Серверы недоступны",
"no_users": "Пользователи недоступны"
},
"tasks": {
"clear_completed": "Очистить завершённые",