feat: improve nodes, workspaces, settings pages; add favicon and logo; update locales
This commit is contained in:
@@ -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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 658 B |
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
@@ -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({
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -1,64 +1,316 @@
|
|||||||
|
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">
|
||||||
<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">
|
<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-start justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<Link to={`/nodes/${srv.id}`} className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
|
<Server className="h-5 w-5" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
|
<div className="min-w-0">
|
||||||
{srv.is_online ? t('common.online') : t('common.offline')}
|
<p className="font-semibold truncate">{srv.name}</p>
|
||||||
</Badge>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<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" />
|
<Monitor className="h-3.5 w-3.5" />
|
||||||
<span>{srvVMs.length} VM</span>
|
<span>{srvVMs.length} VM</span>
|
||||||
</div>
|
</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" />
|
<Container className="h-3.5 w-3.5" />
|
||||||
<span>{srvCTs.length} LXC</span>
|
<span>{srvCTs.length} LXC</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
|
||||||
|
{srv.is_online ? t('common.online') : t('common.offline')}
|
||||||
|
</Badge>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
setPanelName(settings.panel_name || '');
|
if (settings) {
|
||||||
setLang(settings.language || 'ru');
|
setPanelName(settings.panel_name || '');
|
||||||
}
|
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(() => {
|
||||||
setMaxAttempts(String(security.max_login_attempts ?? 5));
|
if (security) {
|
||||||
setLockoutDuration(String(security.lockout_duration ?? 300));
|
setMaxAttempts(String(security.max_login_attempts ?? 5));
|
||||||
setSessionTimeout(String(security.session_timeout ?? 3600));
|
setLockoutDuration(String(security.lockout_duration ?? 300));
|
||||||
}
|
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(() => {
|
||||||
setSmtpHost(String(channels.smtp.host ?? ''));
|
if (channels?.smtp) {
|
||||||
setSmtpPort(String(channels.smtp.port ?? 587));
|
setSmtpHost(String(channels.smtp.host ?? ''));
|
||||||
setSmtpUser(String(channels.smtp.username ?? ''));
|
setSmtpPort(String(channels.smtp.port ?? 587));
|
||||||
setSmtpFrom(String(channels.smtp.from_email ?? ''));
|
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 ?? ''));
|
}, [channels?.smtp]);
|
||||||
setTgChatId(String(channels.telegram.chat_id ?? ''));
|
|
||||||
}
|
useEffect(() => {
|
||||||
|
if (channels?.telegram) {
|
||||||
|
setTgToken(String(channels.telegram.telegram_bot_token ?? channels.telegram.bot_token ?? ''));
|
||||||
|
setTgChatId(String(channels.telegram.chat_id ?? ''));
|
||||||
|
}
|
||||||
|
}, [channels?.telegram]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -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>
|
setEditOpen(false);
|
||||||
<Button onClick={() => {
|
}}
|
||||||
if (editId) {
|
/>
|
||||||
updateWorkspace.mutate({ id: editId, name: editName, description: editDescription });
|
)}
|
||||||
}
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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`, {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Очистить завершённые",
|
||||||
|
|||||||
Reference in New Issue
Block a user