feat: improve nodes, workspaces, settings pages; add favicon and logo; update locales
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user