Compare commits
2 Commits
09186e23b9
...
32434e0ef3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32434e0ef3 | ||
|
|
990ca14c63 |
@@ -56,13 +56,20 @@ export default function CommandPalette() {
|
||||
];
|
||||
}, [query, isAdmin, permissions, t]);
|
||||
|
||||
// Reset transient UI state whenever the dialog's open state changes, driven
|
||||
// straight from the event (not an effect) to avoid a cascading re-render.
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
setQuery('');
|
||||
setActiveIndex(0);
|
||||
};
|
||||
// Start every opening with an empty query. Done while rendering (not in an
|
||||
// effect, not in onOpenChange): Ctrl+K, the top-bar button and go() flip the
|
||||
// store directly and bypass onOpenChange, so the previous search used to
|
||||
// reappear on the next open.
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
if (open) {
|
||||
setQuery('');
|
||||
setActiveIndex(0);
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (next: boolean) => setOpen(next);
|
||||
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
|
||||
@@ -58,7 +58,10 @@ export default function NotificationsDropdown() {
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{t('notifications.empty')}</div>
|
||||
) : (
|
||||
notifications.slice(0, 20).map(n => (
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-1 p-3 cursor-default" onSelect={e => e.preventDefault()}>
|
||||
// closeOnClick={false}: the mark-read / delete buttons live inside the
|
||||
// item, so a click on them must not close the whole menu (Base UI has
|
||||
// no Radix-style onSelect to cancel).
|
||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-1 p-3 cursor-default" closeOnClick={false}>
|
||||
<div className="flex w-full items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm leading-tight ${n.is_read ? 'text-muted-foreground' : 'font-medium'}`}>
|
||||
|
||||
@@ -62,6 +62,18 @@ export function RequirePermission({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing page ("/", unknown URLs, after login or an identity switch): the
|
||||
* first page the user may open. Sending everybody to /dashboard made users
|
||||
* without dashboard:view bounce off RequirePermission with an "access denied"
|
||||
* toast on every sign-in.
|
||||
*/
|
||||
export function HomeRedirect() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (!user) return <Spinner />;
|
||||
return <Navigate to={firstAccessiblePath(!!user.is_admin, user.permissions) ?? '/profile'} replace />;
|
||||
}
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
|
||||
@@ -80,7 +92,7 @@ export function PublicRoute() {
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function LoginPage() {
|
||||
setTwoFactorRequired(true);
|
||||
return;
|
||||
}
|
||||
navigate('/dashboard');
|
||||
navigate('/');
|
||||
} catch {
|
||||
// Error is already handled in store
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Archive, Play, Calendar, RotateCcw, Trash2, Loader2, Plus, Pencil } from 'lucide-react';
|
||||
|
||||
@@ -99,15 +99,40 @@ function detectVmType(volid: string): 'qemu' | 'lxc' {
|
||||
export default function BackupsPage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState('files');
|
||||
const [selectedServer, setSelectedServer] = useState<string>('');
|
||||
const [serverPick, setSelectedServer] = useState<string>('');
|
||||
const [selectedNode, setSelectedNode] = useState<string>(ALL_NODES);
|
||||
const [selectedStorage, setSelectedStorage] = useState('');
|
||||
const [storagePick, setSelectedStorage] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const { data: servers = [] } = useServers();
|
||||
// The picked server, or the first one when nothing (or a server no longer in
|
||||
// the active workspace) is picked — a stale id would silently empty the list
|
||||
// and make the Select show the raw id instead of a name.
|
||||
const selectedServer = servers.some(s => String(s.id) === serverPick)
|
||||
? serverPick
|
||||
: (servers[0] ? String(servers[0].id) : '');
|
||||
const sid = selectedServer ? Number(selectedServer) : 0;
|
||||
// A different server (picked, or fallen back to) invalidates the node choice.
|
||||
const [nodesOfServer, setNodesOfServer] = useState(selectedServer);
|
||||
if (nodesOfServer !== selectedServer) {
|
||||
setNodesOfServer(selectedServer);
|
||||
setSelectedNode(ALL_NODES);
|
||||
setSelectedStorage('');
|
||||
}
|
||||
const { data: nodesData } = useNodes(sid);
|
||||
const { data: storagesData } = useBackupStorages(sid);
|
||||
const allStorages = useMemo(
|
||||
() => (storagesData?.storages || []) as { storage: string; type: string; content: string; nodes?: string; used?: number; total?: number }[],
|
||||
[storagesData],
|
||||
);
|
||||
const storages = useMemo(
|
||||
() => allStorages.filter(s => (s.content || '').split(',').includes('backup')),
|
||||
[allStorages],
|
||||
);
|
||||
// The picked backup storage, or the first backup-capable one.
|
||||
const selectedStorage = storages.some(s => s.storage === storagePick)
|
||||
? storagePick
|
||||
: (storages[0]?.storage ?? '');
|
||||
const nodeParam = selectedNode === ALL_NODES ? '' : selectedNode;
|
||||
const { data: backupsData } = useBackupList(sid, nodeParam, selectedStorage);
|
||||
const { data: jobsData } = useBackupJobs();
|
||||
@@ -151,14 +176,6 @@ export default function BackupsPage() {
|
||||
const [bulkConfirm, setBulkConfirm] = useState(false);
|
||||
|
||||
const nodes = useMemo(() => (nodesData?.nodes || []), [nodesData]);
|
||||
const allStorages = useMemo(
|
||||
() => (storagesData?.storages || []) as { storage: string; type: string; content: string; nodes?: string; used?: number; total?: number }[],
|
||||
[storagesData],
|
||||
);
|
||||
const storages = useMemo(
|
||||
() => allStorages.filter(s => (s.content || '').split(',').includes('backup')),
|
||||
[allStorages],
|
||||
);
|
||||
// Storages that can hold restored guest disks on a given node.
|
||||
// qemu => content 'images', lxc => 'rootdir', 'any' => either (bulk, mixed types).
|
||||
const diskStoragesFor = (node: string, vmType: 'qemu' | 'lxc' | 'any') => {
|
||||
@@ -171,7 +188,7 @@ export default function BackupsPage() {
|
||||
return true;
|
||||
});
|
||||
};
|
||||
const allBackups = (backupsData?.backups || []) as BackupItem[];
|
||||
const allBackups = useMemo(() => (backupsData?.backups || []) as BackupItem[], [backupsData]);
|
||||
const backups = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return allBackups;
|
||||
@@ -224,36 +241,6 @@ export default function BackupsPage() {
|
||||
return map;
|
||||
}, [servers]);
|
||||
|
||||
// Auto-select first server when list arrives
|
||||
useEffect(() => {
|
||||
if (!selectedServer && servers.length > 0) {
|
||||
setSelectedServer(String(servers[0].id));
|
||||
}
|
||||
}, [servers, selectedServer]);
|
||||
|
||||
// Drop a stale server selection when its server is not in the active
|
||||
// workspace anymore (e.g. after switching workspaces) — otherwise the list
|
||||
// silently empties and the Select shows the raw server id instead of a name.
|
||||
useEffect(() => {
|
||||
if (selectedServer && servers.length > 0 && !servers.some(s => String(s.id) === selectedServer)) {
|
||||
setSelectedServer('');
|
||||
setSelectedNode(ALL_NODES);
|
||||
setSelectedStorage('');
|
||||
}
|
||||
}, [servers, selectedServer]);
|
||||
|
||||
// Auto-select first backup-capable storage when none selected (or current is unavailable)
|
||||
useEffect(() => {
|
||||
if (!sid) return;
|
||||
if (storages.length === 0) {
|
||||
if (selectedStorage) setSelectedStorage('');
|
||||
return;
|
||||
}
|
||||
if (!selectedStorage || !storages.some(s => s.storage === selectedStorage)) {
|
||||
setSelectedStorage(storages[0].storage);
|
||||
}
|
||||
}, [storages, sid, selectedStorage]);
|
||||
|
||||
const openRestore = (b: BackupItem) => {
|
||||
setRestoreTarget(b);
|
||||
setRestoreTargetNode(b.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node || '');
|
||||
@@ -300,9 +287,12 @@ export default function BackupsPage() {
|
||||
|
||||
// --- Bulk restore selection helpers (one archive per VMID) ---
|
||||
// Reset selection whenever the visible backup set changes (server/node/storage switch).
|
||||
useEffect(() => {
|
||||
const backupSetKey = `${sid}|${nodeParam}|${selectedStorage}`;
|
||||
const [selectionSetKey, setSelectionSetKey] = useState(backupSetKey);
|
||||
if (selectionSetKey !== backupSetKey) {
|
||||
setSelectionSetKey(backupSetKey);
|
||||
setSelectedBackups(new Map());
|
||||
}, [sid, nodeParam, selectedStorage]);
|
||||
}
|
||||
|
||||
const toggleBackupSelected = (b: BackupItem) => {
|
||||
setSelectedBackups(prev => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff, AlertTriangle, Search } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -357,9 +357,12 @@ function NativeJobDialog({ serverId, open, onOpenChange, editing, backupStorages
|
||||
const update = useUpdateProxmoxJob(serverId);
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
// Refill the form each time the dialog opens (or is pointed at another job).
|
||||
const [filledFor, setFilledFor] = useState({ open: false, editing: null as NativeJob | null });
|
||||
if (filledFor.open !== open || filledFor.editing !== editing) {
|
||||
setFilledFor({ open, editing });
|
||||
if (open) setForm(jobToForm(editing));
|
||||
}, [open, editing]);
|
||||
}
|
||||
|
||||
const set = <K extends keyof NativeJobForm>(k: K, v: NativeJobForm[K]) =>
|
||||
setForm(prev => ({ ...prev, [k]: v }));
|
||||
@@ -494,19 +497,11 @@ export default function StoragesTab() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { data: servers = [] } = useServers();
|
||||
const [serverId, setServerId] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverId === 0 && servers.length > 0) setServerId(servers[0].id);
|
||||
}, [servers, serverId]);
|
||||
// Сбросить протухший выбор сервера, если его нет в списке активной рабочей
|
||||
// области (например, после переключения рабочей области) — иначе выбор
|
||||
// молча остаётся, а Select показывает голый id сервера вместо имени.
|
||||
useEffect(() => {
|
||||
if (serverId !== 0 && servers.length > 0 && !servers.some((s) => s.id === serverId)) {
|
||||
setServerId(0);
|
||||
}
|
||||
}, [servers, serverId]);
|
||||
const [serverPick, setServerId] = useState(0);
|
||||
// Выбранный сервер или первый из списка. Протухший выбор (сервера нет в
|
||||
// активной рабочей области, например после её переключения) не держим —
|
||||
// иначе Select показывает голый id сервера вместо имени.
|
||||
const serverId = servers.some((s) => s.id === serverPick) ? serverPick : (servers[0]?.id ?? 0);
|
||||
|
||||
const { data: storagesData, isLoading } = useBackupStorages(serverId);
|
||||
const { data: jobsData } = useProxmoxBackupJobs(serverId);
|
||||
|
||||
@@ -636,7 +636,7 @@ export default function ClusterPage() {
|
||||
const [ejectState, setEjectState] = useState<{ via: ClusterServerEntry; node: ClusterServerEntry } | null>(null);
|
||||
|
||||
const standalone = topology?.standalone ?? [];
|
||||
const clusters = topology?.clusters ?? {};
|
||||
const clusters = useMemo(() => topology?.clusters ?? {}, [topology]);
|
||||
const clusterMembers = useMemo(() => Object.values(clusters).flat(), [clusters]);
|
||||
const hasClusters = Object.keys(clusters).length > 0;
|
||||
|
||||
|
||||
@@ -368,14 +368,18 @@ export default function ConsolePage() {
|
||||
}, [startSession, cleanupSession]);
|
||||
|
||||
// ==================== Fullscreen ====================
|
||||
// Driven by fullscreenchange: leaving with Esc (or a refused request) never
|
||||
// reached the toggle, so the button kept showing the wrong icon.
|
||||
useEffect(() => {
|
||||
const sync = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', sync);
|
||||
return () => document.removeEventListener('fullscreenchange', sync);
|
||||
}, []);
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
const req = document.fullscreenElement
|
||||
? document.exitFullscreen()
|
||||
: document.documentElement.requestFullscreen();
|
||||
req.catch(() => { /* denied by the browser — the icon stays as is */ });
|
||||
};
|
||||
|
||||
// ==================== Send Ctrl+Alt+Del (VNC) ====================
|
||||
@@ -467,7 +471,7 @@ export default function ConsolePage() {
|
||||
<Badge variant="outline" className="text-xs border-border/50">
|
||||
{vmTypeLabel(type)} #{vmid}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">Node: {node}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('common.node', 'Node')}: {node}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-2xs ${
|
||||
|
||||
@@ -32,6 +32,12 @@ export default function NodeShellPage() {
|
||||
|
||||
const sid = Number(serverId);
|
||||
|
||||
// `t` changes identity on a language switch. As a dependency of connect() it
|
||||
// tore the session down and reconnected — for cmd=upgrade that starts
|
||||
// `apt dist-upgrade` on the node a second time. Read it through a ref.
|
||||
const tRef = useRef(t);
|
||||
useEffect(() => { tRef.current = t; }, [t]);
|
||||
|
||||
const cleanupSession = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
@@ -111,12 +117,12 @@ export default function NodeShellPage() {
|
||||
ws.onclose = () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(t('console.terminal_closed', 'Соединение с терминалом закрыто'));
|
||||
setErrorMsg(tRef.current('console.terminal_closed', 'Соединение с терминалом закрыто'));
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (guard.cancelled) return;
|
||||
setStatus('error');
|
||||
setErrorMsg(t('console.terminal_error', 'Ошибка соединения с терминалом'));
|
||||
setErrorMsg(tRef.current('console.terminal_error', 'Ошибка соединения с терминалом'));
|
||||
};
|
||||
|
||||
terminal.onData((data: string) => {
|
||||
@@ -149,7 +155,7 @@ export default function NodeShellPage() {
|
||||
setStatus('error');
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
|
||||
}
|
||||
}, [sid, node, cmd, t]);
|
||||
}, [sid, node, cmd]);
|
||||
|
||||
const startSession = useCallback(() => {
|
||||
if (guardRef.current) guardRef.current.cancelled = true;
|
||||
@@ -170,14 +176,17 @@ export default function NodeShellPage() {
|
||||
};
|
||||
}, [startSession, cleanupSession]);
|
||||
|
||||
// Driven by fullscreenchange: leaving with Esc never reached the toggle.
|
||||
useEffect(() => {
|
||||
const sync = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', sync);
|
||||
return () => document.removeEventListener('fullscreenchange', sync);
|
||||
}, []);
|
||||
const toggleFullscreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
const req = document.fullscreenElement
|
||||
? document.exitFullscreen()
|
||||
: document.documentElement.requestFullscreen();
|
||||
req.catch(() => { /* denied by the browser */ });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -191,7 +200,7 @@ export default function NodeShellPage() {
|
||||
<><TerminalSquare className="mr-1 h-3 w-3" />Shell</>
|
||||
)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">Node: {node}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('common.node', 'Node')}: {node}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-2xs ${
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Download } from 'lucide-react';
|
||||
import {
|
||||
@@ -50,9 +50,9 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
|
||||
const { t } = useTranslation();
|
||||
const isQcow2 = image?.kind === 'qcow2';
|
||||
|
||||
const [storage, setStorage] = useState(''); // download storage (import / vztmpl)
|
||||
const [storagePick, setStorage] = useState(''); // download storage (import / vztmpl)
|
||||
const [toTemplate, setToTemplate] = useState(true);
|
||||
const [diskStorage, setDiskStorage] = useState(''); // VM disk storage (images)
|
||||
const [diskStoragePick, setDiskStorage] = useState(''); // VM disk storage (images)
|
||||
const [vmid, setVmid] = useState<string>('');
|
||||
const [cores, setCores] = useState(2);
|
||||
const [memory, setMemory] = useState(2048);
|
||||
@@ -77,17 +77,24 @@ export default function DownloadImageDialog({ open, onClose, serverId, node, ima
|
||||
const download = useDownloadImage(serverId);
|
||||
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||
|
||||
useEffect(() => {
|
||||
// Fresh choices on every opening.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setStorage(storages.length === 1 ? storages[0].storage : '');
|
||||
setStorage('');
|
||||
setDiskStorage('');
|
||||
setToTemplate(true);
|
||||
setVmid('');
|
||||
}
|
||||
}, [open, storages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setDiskStorage(diskStorages.length === 1 ? diskStorages[0].storage : '');
|
||||
}, [open, diskStorages]);
|
||||
}
|
||||
// A storage that is the only option is picked for the user.
|
||||
const storage = storages.some((s) => s.storage === storagePick)
|
||||
? storagePick
|
||||
: (storages.length === 1 ? storages[0].storage : '');
|
||||
const diskStorage = diskStorages.some((s) => s.storage === diskStoragePick)
|
||||
? diskStoragePick
|
||||
: (diskStorages.length === 1 ? diskStorages[0].storage : '');
|
||||
|
||||
if (!image) return null;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Download, Upload, Wand2, FileUp, X } from 'lucide-react';
|
||||
import {
|
||||
@@ -72,7 +72,7 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
|
||||
const [mode, setMode] = useState<Mode>('url');
|
||||
const [url, setUrl] = useState('');
|
||||
const [filename, setFilename] = useState('');
|
||||
const [storage, setStorage] = useState('');
|
||||
const [storagePick, setStorage] = useState('');
|
||||
const [checksum, setChecksum] = useState('');
|
||||
const [checksumAlgo, setChecksumAlgo] = useState('sha256');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
@@ -90,20 +90,26 @@ export default function DownloadIsoDialog({ open, onClose, serverId, node, sourc
|
||||
const resolveFilename = useResolveFilename();
|
||||
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode('url');
|
||||
setUrl(source?.url ?? '');
|
||||
setFilename(source?.url ? filenameFromUrl(source.url) : '');
|
||||
setChecksum('');
|
||||
setChecksumAlgo('sha256');
|
||||
setSelectedFile(null);
|
||||
setIsDragging(false);
|
||||
}, [open, source?.url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setStorage(storages.length === 1 ? storages[0].storage : '');
|
||||
}, [open, storages]);
|
||||
// Fresh form on every opening (and when the dialog is pointed at another URL).
|
||||
const sourceUrl = source?.url;
|
||||
const [filledFor, setFilledFor] = useState<{ open: boolean; url?: string }>({ open: false });
|
||||
if (filledFor.open !== open || filledFor.url !== sourceUrl) {
|
||||
setFilledFor({ open, url: sourceUrl });
|
||||
if (open) {
|
||||
setMode('url');
|
||||
setUrl(sourceUrl ?? '');
|
||||
setFilename(sourceUrl ? filenameFromUrl(sourceUrl) : '');
|
||||
setStorage('');
|
||||
setChecksum('');
|
||||
setChecksumAlgo('sha256');
|
||||
setSelectedFile(null);
|
||||
setIsDragging(false);
|
||||
}
|
||||
}
|
||||
// A storage that is the only option is picked for the user.
|
||||
const storage = storages.some((s) => s.storage === storagePick)
|
||||
? storagePick
|
||||
: (storages.length === 1 ? storages[0].storage : '');
|
||||
|
||||
// Пользователь мог не тронуть имя — тогда ведём его за URL.
|
||||
const effectiveFilename = filename.trim() || filenameFromUrl(url);
|
||||
|
||||
@@ -46,7 +46,7 @@ export type ImageCatalogSection = 'vm-images' | 'lxc' | 'iso' | 'repositories';
|
||||
export default function ImageCatalogPanel({ section }: { section: ImageCatalogSection }) {
|
||||
const { t } = useTranslation();
|
||||
const [serverId, setServerId] = useState<number | null>(null);
|
||||
const [node, setNode] = useState<string>('');
|
||||
const [nodePick, setNode] = useState<string>('');
|
||||
const [search, setSearch] = useState('');
|
||||
// По умолчанию показываем amd64 (доминирующая платформа PVE); уточняется по арх ноды
|
||||
const [archFilter, setArchFilter] = useState<string>('amd64');
|
||||
@@ -60,6 +60,8 @@ export default function ImageCatalogPanel({ section }: { section: ImageCatalogSe
|
||||
const isAdmin = !!profile?.is_admin;
|
||||
const { data: nodesResp } = useNodes(serverId ?? 0);
|
||||
const nodes = nodesResp?.nodes ?? [];
|
||||
// Выбранная нода, а пока выбора нет — первая нода сервера.
|
||||
const node = nodePick || nodes[0]?.node || '';
|
||||
|
||||
const { data: catalog } = useImageCatalog();
|
||||
const { data: lxcRepo = [] } = useImageLXCTemplates(serverId ?? undefined, node || undefined);
|
||||
@@ -79,9 +81,6 @@ export default function ImageCatalogPanel({ section }: { section: ImageCatalogSe
|
||||
setNode('');
|
||||
}
|
||||
}, [servers, serverId]);
|
||||
useEffect(() => {
|
||||
if (!node && nodes.length > 0) setNode(nodes[0].node);
|
||||
}, [nodes, node]);
|
||||
// Подстроить фильтр архитектуры под выбранную ноду (если арх определилась)
|
||||
useEffect(() => {
|
||||
if (nodeArch?.arch) setArchFilter(nodeArch.arch);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -49,19 +49,21 @@ function MirrorFormDialog({
|
||||
const update = useUpdateMirror();
|
||||
const pending = create.isPending || update.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing) {
|
||||
// Refill the form each time the dialog opens (or is pointed at another mirror).
|
||||
const [filledFor, setFilledFor] = useState({ open: false, editing: null as ImageMirrorItem | null });
|
||||
if (filledFor.open !== open || filledFor.editing !== editing) {
|
||||
setFilledFor({ open, editing });
|
||||
if (open && editing) {
|
||||
setForm({
|
||||
name: editing.name, kind: editing.kind, os: editing.os || '', version: editing.version || '',
|
||||
arch: editing.arch, url: editing.url || '', template: editing.template || '',
|
||||
checksum: editing.checksum || '', checksum_algorithm: editing.checksum_algorithm || '',
|
||||
description: editing.description || '', enabled: editing.enabled,
|
||||
});
|
||||
} else {
|
||||
} else if (open) {
|
||||
setForm(EMPTY);
|
||||
}
|
||||
}, [open, editing]);
|
||||
}
|
||||
|
||||
const set = (k: keyof ImageMirrorInput, v: string | boolean) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Network } from 'lucide-react';
|
||||
import {
|
||||
@@ -76,7 +76,10 @@ export default function AddNetworkInterfaceDialog({ open, onClose, serverId, vmi
|
||||
return `net${n}`;
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fresh form on every opening.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setModel('virtio');
|
||||
setBridge('');
|
||||
@@ -88,7 +91,7 @@ export default function AddNetworkInterfaceDialog({ open, onClose, serverId, vmi
|
||||
setIpCidr('');
|
||||
setGateway('');
|
||||
}
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
const validationError = (): string | null => {
|
||||
if (!bridge) return t('netif.err_bridge_required', 'Выберите сетевой мост');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import {
|
||||
@@ -56,7 +56,10 @@ export default function AddPciDeviceDialog({ open, onClose, serverId, vmid, node
|
||||
|
||||
const hostpciKey = useMemo(() => nextHostpciKey(config), [config]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fresh form on every opening.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setDeviceId('');
|
||||
setPcie(true);
|
||||
@@ -64,7 +67,7 @@ export default function AddPciDeviceDialog({ open, onClose, serverId, vmid, node
|
||||
setPrimaryGpu(false);
|
||||
setRomBar(true);
|
||||
}
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
const buildValue = (): string => {
|
||||
// "All functions" пробрасывает всё устройство: 0000:01:00 (без .функции)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
import {
|
||||
@@ -54,18 +54,21 @@ export default function AddPhysicalDiskDialog({ open, onClose, serverId, vmid, n
|
||||
const [ssd, setSsd] = useState(false);
|
||||
const [discard, setDiscard] = useState(false);
|
||||
|
||||
const disks = data?.disks ?? [];
|
||||
const disks = useMemo(() => data?.disks ?? [], [data]);
|
||||
const diskKey = useMemo(() => nextDiskKey(bus, config), [bus, config]);
|
||||
const selected = useMemo(() => disks.find((d) => (d.passthrough_path || d.devpath) === devpath), [disks, devpath]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fresh form on every opening.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setDevpath('');
|
||||
setBus('scsi');
|
||||
setSsd(false);
|
||||
setDiscard(false);
|
||||
}
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!devpath) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Usb } from 'lucide-react';
|
||||
import {
|
||||
@@ -54,12 +54,15 @@ export default function AddUsbDeviceDialog({ open, onClose, serverId, vmid, node
|
||||
|
||||
const usbKey = useMemo(() => nextUsbKey(config), [config]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fresh form on every opening.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setSelected('');
|
||||
setUsb3(false);
|
||||
}
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!selected) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRightLeft } from 'lucide-react';
|
||||
import {
|
||||
@@ -30,18 +30,15 @@ export default function BulkMigrateDialog({
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { data: nodesData } = useNodes(serverId);
|
||||
const [target, setTarget] = useState('');
|
||||
const [targetPick, setTarget] = useState('');
|
||||
const [online, setOnline] = useState(true);
|
||||
|
||||
const nodeNames = useMemo(() => (nodesData?.nodes ?? []).map((n) => n.node), [nodesData]);
|
||||
const singleSource = sourceNodes.length === 1 ? sourceNodes[0] : null;
|
||||
|
||||
// Автовыбор первой подходящей ноды при открытии.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const candidates = nodeNames.filter((n) => n !== singleSource);
|
||||
if (!target || !candidates.includes(target)) setTarget(candidates[0] ?? '');
|
||||
}, [open, nodeNames, singleSource]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Выбранная нода, а пока выбора нет (или он стал недопустим) — первая подходящая.
|
||||
const candidates = nodeNames.filter((n) => n !== singleSource);
|
||||
const target = candidates.includes(targetPick) ? targetPick : (candidates[0] ?? '');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
@@ -44,9 +44,11 @@ function NumericInput({ value, onChange, ...props }: Omit<React.ComponentProps<t
|
||||
onChange: (n: number) => void;
|
||||
}) {
|
||||
const [raw, setRaw] = useState(String(value));
|
||||
const committed = useRef(value);
|
||||
if (value !== committed.current) {
|
||||
committed.current = value;
|
||||
// Last value this input reported (or received). Kept in state, not a ref:
|
||||
// refs must not be read or written during render.
|
||||
const [committed, setCommitted] = useState(value);
|
||||
if (value !== committed) {
|
||||
setCommitted(value);
|
||||
setRaw(String(value));
|
||||
}
|
||||
return (
|
||||
@@ -58,7 +60,7 @@ function NumericInput({ value, onChange, ...props }: Omit<React.ComponentProps<t
|
||||
setRaw(e.target.value);
|
||||
const n = e.target.value === '' ? 0 : Number(e.target.value);
|
||||
if (!Number.isNaN(n)) {
|
||||
committed.current = n;
|
||||
setCommitted(n);
|
||||
onChange(n);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Disc, Eye, EyeOff, Sparkles, Copy, KeyRound, FileText, Terminal as TerminalIcon, Play, Square, RotateCcw, Power, AlertTriangle, ArrowRightLeft } from 'lucide-react';
|
||||
import {
|
||||
@@ -110,7 +110,10 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
|
||||
const effectiveNode = targetNode || node;
|
||||
const { data: storages = [] } = useLXCStorages(serverId, effectiveNode);
|
||||
|
||||
useEffect(() => {
|
||||
// Fresh form on every opening.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setNewName('');
|
||||
setFull(true);
|
||||
@@ -119,7 +122,7 @@ function CloneDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<P
|
||||
setDescription('');
|
||||
setOwnerId(null);
|
||||
}
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!newName.trim()) return;
|
||||
@@ -252,13 +255,17 @@ function MigrateDialog({ open, onClose, serverId, vmid, type, node, name }: Omit
|
||||
const isRunning = status?.status === 'running';
|
||||
const { data: storages = [] } = useLXCStorages(serverId, targetNode || node);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Fresh form on every opening; "online" follows the guest's state, which
|
||||
// may only arrive after the dialog opened.
|
||||
const [filledFor, setFilledFor] = useState({ open: false, isRunning: false });
|
||||
if (filledFor.open !== open || filledFor.isRunning !== isRunning) {
|
||||
setFilledFor({ open, isRunning });
|
||||
if (open && !filledFor.open) {
|
||||
setTargetNode('');
|
||||
setTargetStorage('');
|
||||
setOnline(isRunning);
|
||||
}
|
||||
}, [open, isRunning]);
|
||||
if (open) setOnline(isRunning);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!targetNode) return;
|
||||
@@ -372,16 +379,20 @@ function RemoteMigrateDialog({ open, onClose, serverId, vmid, type, node, name }
|
||||
const { data: networksResp } = useNodeNetworks(targetServerId ?? 0, targetNode);
|
||||
const bridges = (networksResp?.interfaces ?? []).filter((i) => i.type === 'bridge');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Fresh form on every opening; "online" follows the guest's state, which
|
||||
// may only arrive after the dialog opened.
|
||||
const [filledFor, setFilledFor] = useState({ open: false, isRunning: false });
|
||||
if (filledFor.open !== open || filledFor.isRunning !== isRunning) {
|
||||
setFilledFor({ open, isRunning });
|
||||
if (open && !filledFor.open) {
|
||||
setTargetServerId(null);
|
||||
setTargetNode('');
|
||||
setTargetStorage('');
|
||||
setTargetBridge('');
|
||||
setOnline(isRunning);
|
||||
setDeleteSource(true);
|
||||
}
|
||||
}, [open, isRunning]);
|
||||
if (open) setOnline(isRunning);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!targetServerId || !targetNode) return;
|
||||
@@ -669,13 +680,17 @@ function NotesDialog({ open, onClose, serverId, vmid, type, node, description }:
|
||||
const { t } = useTranslation();
|
||||
const config = useVMConfig(serverId, vmid, type, node, open);
|
||||
const remote = (config.data?.description as string | undefined) ?? description ?? '';
|
||||
const [value, setValue] = useState('');
|
||||
// Until the user types, the field mirrors the stored notes (which may load
|
||||
// after the dialog opened); closing the dialog discards the edit.
|
||||
const [edited, setEdited] = useState('');
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !touched) setValue(remote);
|
||||
const [wasOpen, setWasOpen] = useState(open);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (!open) setTouched(false);
|
||||
}, [open, remote, touched]);
|
||||
}
|
||||
const value = touched ? edited : remote;
|
||||
const setValue = setEdited;
|
||||
|
||||
const mut = useUpdateNotes(serverId, vmid, type, node);
|
||||
|
||||
@@ -831,9 +846,13 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
|
||||
return v.split(',')[0] || null;
|
||||
}, [config.data, device]);
|
||||
|
||||
useEffect(() => {
|
||||
// Preselect the ISO mounted on the chosen device whenever the dialog opens,
|
||||
// the device changes or the config (re)loads.
|
||||
const [filledFor, setFilledFor] = useState({ open: false, device: '', currentVolid: null as string | null });
|
||||
if (filledFor.open !== open || filledFor.device !== device || filledFor.currentVolid !== currentVolid) {
|
||||
setFilledFor({ open, device, currentVolid });
|
||||
if (open) setVolid(currentVolid || '');
|
||||
}, [open, device, currentVolid]);
|
||||
}
|
||||
|
||||
if (!isQemu) {
|
||||
return (
|
||||
@@ -967,7 +986,7 @@ function BackupDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<
|
||||
const { t } = useTranslation();
|
||||
const { data: storagesData } = useBackupStorages(open ? serverId : 0);
|
||||
const createBackup = useCreateBackup();
|
||||
const [storage, setStorage] = useState('');
|
||||
const [storagePick, setStorage] = useState('');
|
||||
const [mode, setMode] = useState('snapshot');
|
||||
const [compress, setCompress] = useState('zstd');
|
||||
|
||||
@@ -977,12 +996,8 @@ function BackupDialog({ open, onClose, serverId, vmid, type, node, name }: Omit<
|
||||
.filter((s) => (s.content || '').includes('backup'))
|
||||
.filter((s) => !s.nodes || s.nodes.split(',').includes(node));
|
||||
}, [storagesData, node]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && storages.length > 0 && !storages.some((s) => s.storage === storage)) {
|
||||
setStorage(storages[0].storage);
|
||||
}
|
||||
}, [open, storages, storage]);
|
||||
// The picked storage, or the first suitable one.
|
||||
const storage = storages.some((s) => s.storage === storagePick) ? storagePick : (storages[0]?.storage ?? '');
|
||||
|
||||
const submit = () => {
|
||||
if (!storage) return;
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function InstanceDetailPage() {
|
||||
{ action: pendingPower },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`${pendingPower} sent successfully`);
|
||||
toast.success(t('console.action_sent'));
|
||||
setPendingPower(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -168,7 +168,7 @@ function RowActionMenu({ vm }: { vm: VMInstance }) {
|
||||
{ action: pendingPower },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`${pendingPower} sent for ${vm.name || vm.vmid}`);
|
||||
toast.success(t('console.action_sent'), { description: vm.name || String(vm.vmid) });
|
||||
setPendingPower(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
@@ -1119,7 +1119,6 @@ export default function InstancesPage() {
|
||||
},
|
||||
],
|
||||
// Volatile data is read via cellCtxRef (see above) — only l10n identities here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t, bulkActionLabel]
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cpu, Usb, HardDrive, Plus, Trash2, MemoryStick, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -86,14 +86,16 @@ function PlatformCard({
|
||||
serverId, node, needsEfiDisk ? 'images' : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
// Refill from each newly loaded config (also the one already cached on mount).
|
||||
const [syncedConfig, setSyncedConfig] = useState<typeof config>(undefined);
|
||||
if (config && config !== syncedConfig) {
|
||||
setSyncedConfig(config);
|
||||
setBios(typeof config.bios === 'string' ? config.bios : 'seabios');
|
||||
setMachine(machineToOption(config.machine));
|
||||
setVga(typeof config.vga === 'string' ? String(config.vga).split(',')[0] : 'std');
|
||||
setScsihw(typeof config.scsihw === 'string' ? config.scsihw : 'virtio-scsi-single');
|
||||
setSockets(config.sockets != null ? String(config.sockets) : '');
|
||||
}, [config]);
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (needsEfiDisk && !efiStorage) {
|
||||
@@ -226,8 +228,10 @@ function CpuOptionsCard({ serverId, vmid, type, node }: Props) {
|
||||
const [cpuunits, setCpuunits] = useState('');
|
||||
const [numa, setNuma] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
// Refill from each newly loaded config (also the one already cached on mount).
|
||||
const [syncedConfig, setSyncedConfig] = useState<typeof config>(undefined);
|
||||
if (config && config !== syncedConfig) {
|
||||
setSyncedConfig(config);
|
||||
// cpu из конфига может быть "host" или "cputype=host,flags=..."
|
||||
const raw = typeof config.cpu === 'string' ? config.cpu : '';
|
||||
const parsed = raw.includes('cputype=') ? (raw.match(/cputype=([^,]+)/)?.[1] ?? '') : raw;
|
||||
@@ -236,7 +240,7 @@ function CpuOptionsCard({ serverId, vmid, type, node }: Props) {
|
||||
setCpulimit(config.cpulimit != null ? String(config.cpulimit) : '');
|
||||
setCpuunits(config.cpuunits != null ? String(config.cpuunits) : '');
|
||||
setNuma(Number(config.numa) === 1);
|
||||
}, [config]);
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
const updates: Record<string, unknown> = {
|
||||
@@ -344,7 +348,9 @@ function DiskManagementCard({ serverId, vmid, type, node }: Props) {
|
||||
.map(([k]) => k);
|
||||
}, [config, type]);
|
||||
|
||||
const [moveDiskDev, setMoveDiskDev] = useState('');
|
||||
const [moveDiskPick, setMoveDiskDev] = useState('');
|
||||
// The picked disk, or the first one while none (or a removed one) is picked.
|
||||
const moveDiskDev = disks.includes(moveDiskPick) ? moveDiskPick : (disks[0] ?? '');
|
||||
const [moveStorage, setMoveStorage] = useState('');
|
||||
const [moveDelete, setMoveDelete] = useState(true);
|
||||
|
||||
@@ -354,10 +360,6 @@ function DiskManagementCard({ serverId, vmid, type, node }: Props) {
|
||||
const [addSsd, setAddSsd] = useState(false);
|
||||
const [addDiscard, setAddDiscard] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (disks.length && !disks.includes(moveDiskDev)) setMoveDiskDev(disks[0]);
|
||||
}, [disks, moveDiskDev]);
|
||||
|
||||
const doMove = () => {
|
||||
if (!moveDiskDev || !moveStorage) return;
|
||||
moveDisk.mutate(
|
||||
|
||||
@@ -134,7 +134,7 @@ export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
|
||||
const resizeDisk = useResizeDisk(serverId, vmid, type, node);
|
||||
|
||||
const [diskSize, setDiskSize] = useState('');
|
||||
const [diskDevice, setDiskDevice] = useState('');
|
||||
const [diskDevicePick, setDiskDevice] = useState('');
|
||||
const [growResult, setGrowResult] = useState<{ grown: boolean; output: string } | null>(null);
|
||||
|
||||
// Реальные дисковые устройства из конфига Proxmox (исключая cd-rom).
|
||||
@@ -148,16 +148,16 @@ export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
|
||||
}, [config]);
|
||||
|
||||
// Загрузочный диск из boot=order=... — выбираем по умолчанию.
|
||||
const bootOrder = typeof config?.boot === 'string' ? config.boot : '';
|
||||
const bootDisk = useMemo(() => {
|
||||
const order = typeof config?.boot === 'string' ? config.boot : '';
|
||||
const first = order.match(/order=([^;]+)/)?.[1]?.split(';')[0]?.trim();
|
||||
const first = bootOrder.match(/order=([^;]+)/)?.[1]?.split(';')[0]?.trim();
|
||||
return first && diskDevices.includes(first) ? first : undefined;
|
||||
}, [config?.boot, diskDevices]);
|
||||
}, [bootOrder, diskDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (diskDevices.length === 0) return;
|
||||
setDiskDevice((cur) => (diskDevices.includes(cur) ? cur : bootDisk ?? diskDevices[0]));
|
||||
}, [diskDevices, bootDisk]);
|
||||
// The picked disk, or the boot disk (else the first one) until one is picked.
|
||||
const diskDevice = diskDevices.includes(diskDevicePick)
|
||||
? diskDevicePick
|
||||
: (bootDisk ?? diskDevices[0] ?? '');
|
||||
|
||||
// Текущий размер выбранного диска (ГБ). Proxmox не умеет уменьшать диск,
|
||||
// поэтому показываем его и не даём ввести значение меньше текущего.
|
||||
@@ -169,9 +169,11 @@ export function DiskResizeCard({ serverId, vmid, type, node }: Props) {
|
||||
|
||||
// При смене диска подставляем текущий размер как стартовое значение,
|
||||
// чтобы пользователь увеличивал его, а не угадывал.
|
||||
useEffect(() => {
|
||||
if (currentDiskGb != null) setDiskSize(String(Math.ceil(currentDiskGb)));
|
||||
}, [currentDiskGb]);
|
||||
const [sizeFilledFor, setSizeFilledFor] = useState<number | null>(null);
|
||||
if (currentDiskGb != null && currentDiskGb !== sizeFilledFor) {
|
||||
setSizeFilledFor(currentDiskGb);
|
||||
setDiskSize(String(Math.ceil(currentDiskGb)));
|
||||
}
|
||||
|
||||
const handleDiskResize = () => {
|
||||
if (!diskSize || !diskDevice) return;
|
||||
|
||||
@@ -163,16 +163,17 @@ function AutostartCard({ serverId, vmid, type, node }: { serverId: number; vmid:
|
||||
const [up, setUp] = useState('');
|
||||
const [down, setDown] = useState('');
|
||||
|
||||
// Инициализация из конфига Proxmox
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
// Инициализация из каждого загруженного конфига Proxmox (в т.ч. уже закэшированного)
|
||||
const [syncedConfig, setSyncedConfig] = useState<typeof config>(undefined);
|
||||
if (config && config !== syncedConfig) {
|
||||
setSyncedConfig(config);
|
||||
setOnboot(Number(config.onboot) === 1);
|
||||
setProtection(Number(config.protection) === 1);
|
||||
const s = parseStartup(config.startup);
|
||||
setOrder(s.order);
|
||||
setUp(s.up);
|
||||
setDown(s.down);
|
||||
}, [config]);
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
const updates: Record<string, unknown> = {
|
||||
@@ -366,8 +367,10 @@ function CloudInitCard({ serverId, vmid, type, node }: { serverId: number; vmid:
|
||||
return Object.values(config).some(v => typeof v === 'string' && v.includes('cloudinit'));
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
// Refill from each newly loaded config (also the one already cached on mount).
|
||||
const [syncedConfig, setSyncedConfig] = useState<typeof config>(undefined);
|
||||
if (config && config !== syncedConfig) {
|
||||
setSyncedConfig(config);
|
||||
setUser(typeof config.ciuser === 'string' ? config.ciuser : '');
|
||||
setNameserver(typeof config.nameserver === 'string' ? config.nameserver : '');
|
||||
setSearchdomain(typeof config.searchdomain === 'string' ? config.searchdomain : '');
|
||||
@@ -384,7 +387,7 @@ function CloudInitCard({ serverId, vmid, type, node }: { serverId: number; vmid:
|
||||
setIpCidr(ip.match(/ip=([^,]+)/)?.[1] ?? '');
|
||||
setGateway(ip.match(/gw=([^,]+)/)?.[1] ?? '');
|
||||
}
|
||||
}, [config]);
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
let ipconfig0: string | undefined;
|
||||
@@ -640,15 +643,17 @@ function VmOptionsCard({ serverId, vmid, type, node }: { serverId: number; vmid:
|
||||
const [kvm, setKvm] = useState(true);
|
||||
const [hotplug, setHotplug] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
// Refill from each newly loaded config (also the one already cached on mount).
|
||||
const [syncedConfig, setSyncedConfig] = useState<typeof config>(undefined);
|
||||
if (config && config !== syncedConfig) {
|
||||
setSyncedConfig(config);
|
||||
const agentRaw = typeof config.agent === 'string' ? config.agent : String(config.agent ?? '');
|
||||
setAgent(agentRaw.startsWith('1'));
|
||||
setTablet(config.tablet == null ? true : Number(config.tablet) === 1);
|
||||
setKvm(config.kvm == null ? true : Number(config.kvm) === 1);
|
||||
const hp = typeof config.hotplug === 'string' ? config.hotplug : '';
|
||||
setHotplug(config.hotplug == null ? true : hp !== '0' && hp !== '');
|
||||
}, [config]);
|
||||
}
|
||||
|
||||
const lock = typeof config?.lock === 'string' ? config.lock : '';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, RefreshCw, KeyRound, ShieldQuestion, Copy } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -38,13 +38,16 @@ function RealmDialog({ serverId, open, onClose }: { serverId: number; open: bool
|
||||
const [usernameClaim, setUsernameClaim] = useState('sub');
|
||||
const [secure, setSecure] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Fresh form on every opening.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) {
|
||||
setRealm(''); setType('ldap'); setComment('');
|
||||
setServer1(''); setBaseDn(''); setUserAttr('uid'); setDomain('');
|
||||
setIssuerUrl(''); setClientId(''); setUsernameClaim('sub'); setSecure(true);
|
||||
}
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!realm.trim()) { toast.error(t('access.err_realm', 'Укажите имя realm')); return; }
|
||||
@@ -137,9 +140,12 @@ function TokenDialog({ serverId, userid, open, onClose }: { serverId: number; us
|
||||
const [privsep, setPrivsep] = useState(true);
|
||||
const [secret, setSecret] = useState<{ full: string; value: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fresh form on every opening (and the previous secret is never shown again).
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) { setTokenid(''); setComment(''); setPrivsep(true); setSecret(null); }
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!tokenid.trim()) { toast.error(t('access.err_tokenid', 'Укажите ID токена')); return; }
|
||||
@@ -216,11 +222,9 @@ function TokensTab({ serverId }: { serverId: number }) {
|
||||
const confirm = useConfirm();
|
||||
const { data: usersData } = useAccessUsers(serverId);
|
||||
const users = usersData?.users ?? [];
|
||||
const [userid, setUserid] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (users.length > 0 && !users.some(u => u.userid === userid)) setUserid(users[0].userid);
|
||||
}, [users, userid]);
|
||||
const [useridPick, setUserid] = useState('');
|
||||
// The picked user, or the first one while none (or a removed one) is picked.
|
||||
const userid = users.some(u => u.userid === useridPick) ? useridPick : (users[0]?.userid ?? '');
|
||||
|
||||
const { data: tokensData } = useUserTokens(serverId, userid);
|
||||
const tokens = tokensData?.tokens ?? [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HardDrive, RefreshCw, Activity, Trash2, Plus, Database, Eraser } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -103,9 +103,12 @@ function CreateZfsDialog({ serverId, node, disks, open, onClose }: {
|
||||
// Свободные диски (без файловой системы / роли) — кандидаты под пул.
|
||||
const available = useMemo(() => disks.filter(d => !d.used), [disks]);
|
||||
|
||||
useEffect(() => {
|
||||
// Fresh form on every opening.
|
||||
const [wasOpen, setWasOpen] = useState(false);
|
||||
if (open !== wasOpen) {
|
||||
setWasOpen(open);
|
||||
if (open) { setName(''); setRaid('single'); setAshift('12'); setCompression('on'); setSelected(new Set()); }
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
const toggle = (dev: string) => setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -188,10 +191,9 @@ export default function DiskManager({ serverId, nodeNames }: { serverId: number;
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [node, setNode] = useState('');
|
||||
useEffect(() => {
|
||||
if (nodeNames.length > 0 && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||
}, [nodeNames, node]);
|
||||
const [nodePick, setNode] = useState('');
|
||||
// The picked node, or the first one while none (or a vanished one) is picked.
|
||||
const node = nodeNames.includes(nodePick) ? nodePick : (nodeNames[0] ?? '');
|
||||
|
||||
const { data: disksData, isLoading, refetch, isFetching } = useNodeDisks(serverId, node);
|
||||
const { data: zfsData } = useZfsPools(serverId, node);
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function HAResources({ serverId, vms }: { serverId: number; vms:
|
||||
const [form, setForm] = useState<AddForm>(emptyAddForm);
|
||||
const set = <K extends keyof AddForm>(k: K, v: AddForm[K]) => setForm(p => ({ ...p, [k]: v }));
|
||||
|
||||
const resources = data?.resources ?? [];
|
||||
const resources = useMemo(() => data?.resources ?? [], [data]);
|
||||
const groups = data?.groups ?? [];
|
||||
|
||||
// Guests not yet managed by HA, for the add dialog
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, Server, Play, Square, RotateCw, PackageCheck, Download } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -26,10 +26,9 @@ function stateVariant(s?: string): 'default' | 'secondary' | 'destructive' {
|
||||
|
||||
export default function NodeAdminManager({ serverId, nodeNames }: { serverId: number; nodeNames: string[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [node, setNode] = useState('');
|
||||
useEffect(() => {
|
||||
if (nodeNames.length > 0 && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||
}, [nodeNames, node]);
|
||||
const [nodePick, setNode] = useState('');
|
||||
// The picked node, or the first one while none (or a vanished one) is picked.
|
||||
const node = nodeNames.includes(nodePick) ? nodePick : (nodeNames[0] ?? '');
|
||||
|
||||
const { data: servicesData, refetch, isFetching } = useNodeServices(serverId, node);
|
||||
const serviceAction = useServiceAction(serverId, node);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type NodeCertificate,
|
||||
} from '@/hooks/use-node-admin';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useHasPermission } from '@/lib/permissions';
|
||||
|
||||
/** Форматирование unix-времени (сек) в локальную дату; пустое — прочерк. */
|
||||
function fmtDate(ts?: number): string {
|
||||
@@ -39,7 +39,7 @@ function ExpiryBadge({ notafter }: { notafter?: number }) {
|
||||
export default function NodeCertificates({ serverId, node }: { serverId: number; node: string }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const canManage = !!useAuthStore((s) => s.user?.permissions?.['node:manage']);
|
||||
const canManage = useHasPermission('node:manage');
|
||||
|
||||
const { data, isFetching, refetch } = useNodeCertificates(serverId, node);
|
||||
const upload = useUploadNodeCertificate(serverId, node);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useVirtualMachines } from '@/hooks/use-instances';
|
||||
import { useNodePower } from '@/hooks/use-node-admin';
|
||||
import { useConfirm } from '@/components/shared/ConfirmDialog';
|
||||
import { ServerStatusBadge } from '@/components/shared/ServerStatusBadge';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useHasPermission } from '@/lib/permissions';
|
||||
import { StatusDot } from '@/components/shared/status-dot';
|
||||
import { formatBytes, formatUptime, formatPercent } from '@/lib/format';
|
||||
|
||||
@@ -24,7 +24,9 @@ function NodeActionsMenu({ serverId, node }: { serverId: number; node: string })
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const power = useNodePower(serverId, node);
|
||||
const canPower = !!useAuthStore((s) => s.user?.permissions?.['node:power']);
|
||||
// hasPermission, not a raw map lookup: an admin bound to a role gets only
|
||||
// that role's map from /me, yet the backend lets admins through.
|
||||
const canPower = useHasPermission('node:power');
|
||||
|
||||
const doPower = async (command: 'reboot' | 'shutdown') => {
|
||||
const ok = await confirm(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, RefreshCw, ShieldCheck, Power } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -26,10 +26,9 @@ export default function NodeFirewallManager({ serverId, nodeNames }: { serverId:
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [node, setNode] = useState('');
|
||||
useEffect(() => {
|
||||
if (nodeNames.length > 0 && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||
}, [nodeNames, node]);
|
||||
const [nodePick, setNode] = useState('');
|
||||
// The picked node, or the first one while none (or a vanished one) is picked.
|
||||
const node = nodeNames.includes(nodePick) ? nodePick : (nodeNames[0] ?? '');
|
||||
|
||||
const { data: groupsData } = useFirewallGroups(serverId);
|
||||
const { data: optionsData, isLoading, refetch, isFetching } = useNodeFirewallOptions(serverId, node);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,14 +20,12 @@ const PRESETS = [...TIMEFRAME_PRESETS, { tf: 'year', label: '1y' }];
|
||||
|
||||
export default function NodeGraphs({ serverId, nodeNames }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [node, setNode] = useState(nodeNames[0] || '');
|
||||
const [nodePick, setNode] = useState('');
|
||||
const [timeframe, setTimeframe] = useState('hour');
|
||||
|
||||
// Nodes arrive asynchronously; adopt the first one once it shows up, and
|
||||
// fall back if the selected node disappears from the cluster.
|
||||
useEffect(() => {
|
||||
if (nodeNames.length && !nodeNames.includes(node)) setNode(nodeNames[0]);
|
||||
}, [nodeNames, node]);
|
||||
// Nodes arrive asynchronously; use the first one until a node is picked, and
|
||||
// fall back to it if the picked node disappears from the cluster.
|
||||
const node = nodeNames.includes(nodePick) ? nodePick : (nodeNames[0] ?? '');
|
||||
|
||||
const { data: points = [], isLoading } = useNodeMetrics(serverId, node, timeframe);
|
||||
const xTick = tickFormatterFor(timeframe);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Globe, Clock, FileText } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -17,16 +17,17 @@ export default function NodeSystemConfig({ serverId, node }: { serverId: number;
|
||||
const { data: dnsData } = useNodeDns(serverId, node);
|
||||
const setDns = useSetNodeDns(serverId, node);
|
||||
const [dns, setDnsForm] = useState({ search: '', dns1: '', dns2: '', dns3: '' });
|
||||
useEffect(() => {
|
||||
if (dnsData?.dns) {
|
||||
setDnsForm({
|
||||
search: dnsData.dns.search ?? '',
|
||||
dns1: dnsData.dns.dns1 ?? '',
|
||||
dns2: dnsData.dns.dns2 ?? '',
|
||||
dns3: dnsData.dns.dns3 ?? '',
|
||||
});
|
||||
}
|
||||
}, [dnsData]);
|
||||
// Each form below refills from every newly loaded response (also a cached one on mount).
|
||||
const [dnsFrom, setDnsFrom] = useState<typeof dnsData>(undefined);
|
||||
if (dnsData?.dns && dnsData !== dnsFrom) {
|
||||
setDnsFrom(dnsData);
|
||||
setDnsForm({
|
||||
search: dnsData.dns.search ?? '',
|
||||
dns1: dnsData.dns.dns1 ?? '',
|
||||
dns2: dnsData.dns.dns2 ?? '',
|
||||
dns3: dnsData.dns.dns3 ?? '',
|
||||
});
|
||||
}
|
||||
const saveDns = () => setDns.mutate(dns, {
|
||||
onSuccess: () => toast.success(t('nodeadm.dns_saved', 'DNS сохранён')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -36,7 +37,11 @@ export default function NodeSystemConfig({ serverId, node }: { serverId: number;
|
||||
const { data: timeData } = useNodeTime(serverId, node);
|
||||
const setTz = useSetNodeTimezone(serverId, node);
|
||||
const [tz, setTz2] = useState('');
|
||||
useEffect(() => { if (timeData?.time?.timezone) setTz2(timeData.time.timezone); }, [timeData]);
|
||||
const [tzFrom, setTzFrom] = useState<typeof timeData>(undefined);
|
||||
if (timeData?.time?.timezone && timeData !== tzFrom) {
|
||||
setTzFrom(timeData);
|
||||
setTz2(timeData.time.timezone);
|
||||
}
|
||||
const saveTz = () => setTz.mutate(tz, {
|
||||
onSuccess: () => toast.success(t('nodeadm.tz_saved', 'Часовой пояс сохранён')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -46,7 +51,11 @@ export default function NodeSystemConfig({ serverId, node }: { serverId: number;
|
||||
const { data: hostsData } = useNodeHosts(serverId, node);
|
||||
const setHosts = useSetNodeHosts(serverId, node);
|
||||
const [hosts, setHostsForm] = useState('');
|
||||
useEffect(() => { if (hostsData?.hosts?.data != null) setHostsForm(hostsData.hosts.data); }, [hostsData]);
|
||||
const [hostsFrom, setHostsFrom] = useState<typeof hostsData>(undefined);
|
||||
if (hostsData?.hosts?.data != null && hostsData !== hostsFrom) {
|
||||
setHostsFrom(hostsData);
|
||||
setHostsForm(hostsData.hosts.data);
|
||||
}
|
||||
const saveHosts = () => setHosts.mutate({ data: hosts, digest: hostsData?.hosts?.digest }, {
|
||||
onSuccess: () => toast.success(t('nodeadm.hosts_saved', '/etc/hosts сохранён')),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, Monitor, Container, Plus, Pencil, Trash2, Wifi, Loader2, Clock, Layers, LayoutGrid, ExternalLink, KeyRound, FingerprintPattern } from 'lucide-react';
|
||||
@@ -143,9 +143,12 @@ function ServerFormDialog({
|
||||
const [form, setForm] = useState<ServerFormData>(initialData);
|
||||
const testServer = useTestServerCredentials();
|
||||
|
||||
useEffect(() => {
|
||||
// Refill the form each time the dialog opens (or gets other initial data).
|
||||
const [filledFor, setFilledFor] = useState({ open: false, initialData: null as ServerFormData | null });
|
||||
if (filledFor.open !== open || filledFor.initialData !== initialData) {
|
||||
setFilledFor({ open, initialData });
|
||||
if (open) setForm(initialData);
|
||||
}, [open, initialData]);
|
||||
}
|
||||
|
||||
const set = (field: keyof ServerFormData, value: string | boolean) =>
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
@@ -30,9 +30,11 @@ function PoolMembers({ serverId, poolid, vms }: { serverId: number; poolid: stri
|
||||
const updatePool = useUpdatePool(serverId);
|
||||
const [addVmid, setAddVmid] = useState('');
|
||||
|
||||
const members = data?.pool?.members ?? [];
|
||||
const memberVmids = new Set(members.filter(m => m.vmid != null).map(m => m.vmid));
|
||||
const available = useMemo(() => vms.filter(v => !memberVmids.has(v.vmid)), [vms, memberVmids]);
|
||||
const members = useMemo(() => data?.pool?.members ?? [], [data]);
|
||||
const available = useMemo(() => {
|
||||
const memberVmids = new Set(members.filter(m => m.vmid != null).map(m => m.vmid));
|
||||
return vms.filter(v => !memberVmids.has(v.vmid));
|
||||
}, [vms, members]);
|
||||
|
||||
const add = () => {
|
||||
if (!addVmid) return;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Plus, Trash2 } from 'lucide-react';
|
||||
@@ -32,9 +32,11 @@ export default function ScriptEditDialog({ open, onOpenChange, script }: Props)
|
||||
const [content, setContent] = useState('#!/bin/bash\n');
|
||||
const [params, setParams] = useState<ScriptParamField[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (script) {
|
||||
// Refill the form each time the dialog opens (or is pointed at another script).
|
||||
const [filledFor, setFilledFor] = useState<{ open: boolean; script: typeof script }>({ open: false, script: null });
|
||||
if (filledFor.open !== open || filledFor.script !== script) {
|
||||
setFilledFor({ open, script });
|
||||
if (open && script) {
|
||||
setName(script.name);
|
||||
setSlug(script.slug);
|
||||
setDescription(script.description || '');
|
||||
@@ -43,11 +45,11 @@ export default function ScriptEditDialog({ open, onOpenChange, script }: Props)
|
||||
setInterpreter(script.interpreter);
|
||||
setContent(script.content || '');
|
||||
setParams(script.params_schema || []);
|
||||
} else {
|
||||
} else if (open) {
|
||||
setName(''); setSlug(''); setDescription(''); setCategory('');
|
||||
setTargetType('guest'); setInterpreter('/bin/bash'); setContent('#!/bin/bash\n'); setParams([]);
|
||||
}
|
||||
}, [open, script]);
|
||||
}
|
||||
|
||||
const create = useCreateScript();
|
||||
const update = useUpdateScript(script?.id || 0);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Play, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react';
|
||||
@@ -45,15 +45,19 @@ export default function ScriptExecuteDialog({ script, onOpenChange }: Props) {
|
||||
[vms, serverIdNum],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setServerId(''); setNode(''); setGuestKey(''); setExecutionId(null);
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const p of script?.params_schema || []) {
|
||||
defaults[p.name] = p.default != null ? String(p.default) : '';
|
||||
// Fresh form (with the script's parameter defaults) for every script opened.
|
||||
const [filledFor, setFilledFor] = useState<typeof script>(null);
|
||||
if (script !== filledFor) {
|
||||
setFilledFor(script);
|
||||
if (script) {
|
||||
setServerId(''); setNode(''); setGuestKey(''); setExecutionId(null);
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const p of script.params_schema || []) {
|
||||
defaults[p.name] = p.default != null ? String(p.default) : '';
|
||||
}
|
||||
setParamValues(defaults);
|
||||
}
|
||||
setParamValues(defaults);
|
||||
}, [open, script]);
|
||||
}
|
||||
|
||||
const execute = useExecuteScript(script?.id || 0);
|
||||
|
||||
|
||||
@@ -66,13 +66,14 @@ function ProfileTab() {
|
||||
const [newPwd, setNewPwd] = useState('');
|
||||
const [confirmPwd, setConfirmPwd] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
setDisplayName(profile.full_name || '');
|
||||
setEmail(profile.email || '');
|
||||
setLang(profile.language || 'ru');
|
||||
}
|
||||
}, [profile]);
|
||||
// Refill from each newly loaded profile (also the one already cached on mount).
|
||||
const [syncedProfile, setSyncedProfile] = useState<typeof profile>(undefined);
|
||||
if (profile && profile !== syncedProfile) {
|
||||
setSyncedProfile(profile);
|
||||
setDisplayName(profile.full_name || '');
|
||||
setEmail(profile.email || '');
|
||||
setLang(profile.language || 'ru');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -338,8 +339,10 @@ function NotificationPreferencesCard() {
|
||||
const [quietEnd, setQuietEnd] = useState('');
|
||||
const [testChannel, setTestChannel] = useState('all');
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefs) return;
|
||||
// Refill from each newly loaded preferences object (also a cached one on mount).
|
||||
const [syncedPrefs, setSyncedPrefs] = useState<typeof prefs>(undefined);
|
||||
if (prefs && prefs !== syncedPrefs) {
|
||||
setSyncedPrefs(prefs);
|
||||
setEnabled(prefs.enabled);
|
||||
setEmailEnabled(prefs.email_enabled);
|
||||
setEmailCritical(prefs.email_critical_only);
|
||||
@@ -348,7 +351,7 @@ function NotificationPreferencesCard() {
|
||||
setWebhook(prefs.webhook_url ?? '');
|
||||
setQuietStart(prefs.quiet_hours_start ?? '');
|
||||
setQuietEnd(prefs.quiet_hours_end ?? '');
|
||||
}, [prefs]);
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
update.mutate({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } 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';
|
||||
@@ -58,11 +58,12 @@ function PanelTab({ canEdit }: { canEdit: boolean }) {
|
||||
const updatePanel = useUpdatePanelSettings();
|
||||
const [logRetention, setLogRetention] = useState('30');
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setLogRetention(String(settings.log_retention_days || 30));
|
||||
}
|
||||
}, [settings]);
|
||||
// Refill from each newly loaded response (also a cached one on mount).
|
||||
const [syncedFrom, setSyncedFrom] = useState<typeof settings>(undefined);
|
||||
if (settings && settings !== syncedFrom) {
|
||||
setSyncedFrom(settings);
|
||||
setLogRetention(String(settings.log_retention_days || 30));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -93,13 +94,14 @@ function SecurityTab() {
|
||||
const [lockoutDuration, setLockoutDuration] = useState('');
|
||||
const [sessionTimeout, setSessionTimeout] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (security) {
|
||||
setMaxAttempts(String(security.max_login_attempts ?? 5));
|
||||
setLockoutDuration(String(security.lockout_duration_minutes ?? 30));
|
||||
setSessionTimeout(String(security.session_timeout_minutes ?? 60));
|
||||
}
|
||||
}, [security]);
|
||||
// Refill from each newly loaded response (also a cached one on mount).
|
||||
const [syncedFrom, setSyncedFrom] = useState<typeof security>(undefined);
|
||||
if (security && security !== syncedFrom) {
|
||||
setSyncedFrom(security);
|
||||
setMaxAttempts(String(security.max_login_attempts ?? 5));
|
||||
setLockoutDuration(String(security.lockout_duration_minutes ?? 30));
|
||||
setSessionTimeout(String(security.session_timeout_minutes ?? 60));
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -133,20 +135,23 @@ function NotificationChannelsCards() {
|
||||
const [smtpFrom, setSmtpFrom] = useState('');
|
||||
const [tgToken, setTgToken] = useState('');
|
||||
|
||||
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]);
|
||||
// Each block refills from every newly loaded channel config (also a cached one on mount).
|
||||
const smtp = channels?.smtp;
|
||||
const [smtpSyncedFrom, setSmtpSyncedFrom] = useState<typeof smtp>(undefined);
|
||||
if (smtp && smtp !== smtpSyncedFrom) {
|
||||
setSmtpSyncedFrom(smtp);
|
||||
setSmtpHost(String(smtp.host ?? ''));
|
||||
setSmtpPort(String(smtp.port ?? 587));
|
||||
setSmtpUser(String(smtp.username ?? ''));
|
||||
setSmtpFrom(String(smtp.from_email ?? ''));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (channels?.telegram) {
|
||||
setTgToken(String(channels.telegram.telegram_bot_token ?? channels.telegram.bot_token ?? ''));
|
||||
}
|
||||
}, [channels?.telegram]);
|
||||
const telegram = channels?.telegram;
|
||||
const [tgSyncedFrom, setTgSyncedFrom] = useState<typeof telegram>(undefined);
|
||||
if (telegram && telegram !== tgSyncedFrom) {
|
||||
setTgSyncedFrom(telegram);
|
||||
setTgToken(String(telegram.telegram_bot_token ?? telegram.bot_token ?? ''));
|
||||
}
|
||||
|
||||
const testSmtp = useTestSMTP();
|
||||
const botInfo = useTelegramBotInfo();
|
||||
@@ -242,9 +247,12 @@ function UpdateSection() {
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
const [repoEdit, setRepoEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (repo?.repository_url) setRepoUrl(repo.repository_url);
|
||||
}, [repo?.repository_url]);
|
||||
const storedRepoUrl = repo?.repository_url;
|
||||
const [repoSyncedFrom, setRepoSyncedFrom] = useState<string | undefined>(undefined);
|
||||
if (storedRepoUrl && storedRepoUrl !== repoSyncedFrom) {
|
||||
setRepoSyncedFrom(storedRepoUrl);
|
||||
setRepoUrl(storedRepoUrl);
|
||||
}
|
||||
|
||||
const handleSaveRepo = () => {
|
||||
setRepo.mutate(repoUrl, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -28,15 +28,21 @@ export function DeployDialog({ template, onClose }: { template: OSTemplate | nul
|
||||
const [start, setStart] = useState(true);
|
||||
|
||||
// Prefill from the quick-deploy info (authoritative defaults from backend).
|
||||
useEffect(() => {
|
||||
if (info?.template) {
|
||||
setCores(String(info.template.default_cores));
|
||||
setMemory(String(info.template.default_memory));
|
||||
setDisk(String(info.template.default_disk));
|
||||
}
|
||||
}, [info?.template]);
|
||||
const defaults = info?.template;
|
||||
const [prefilledFrom, setPrefilledFrom] = useState<typeof defaults>(undefined);
|
||||
if (defaults && defaults !== prefilledFrom) {
|
||||
setPrefilledFrom(defaults);
|
||||
setCores(String(defaults.default_cores));
|
||||
setMemory(String(defaults.default_memory));
|
||||
setDisk(String(defaults.default_disk));
|
||||
}
|
||||
|
||||
useEffect(() => { if (template) setName(''); }, [template]);
|
||||
// A new template starts with an empty name.
|
||||
const [namedFor, setNamedFor] = useState<OSTemplate | null>(null);
|
||||
if (template && template !== namedFor) {
|
||||
setNamedFor(template);
|
||||
setName('');
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!template) return;
|
||||
@@ -94,12 +100,14 @@ export function DownloadCTDialog({ open, onClose }: { open: boolean; onClose: ()
|
||||
const { t } = useTranslation();
|
||||
const { data: servers = [] } = useServers();
|
||||
const [serverId, setServerId] = useState(0);
|
||||
const [node, setNode] = useState('');
|
||||
const [nodePick, setNode] = useState('');
|
||||
const [storage, setStorage] = useState('');
|
||||
const [template, setTemplate] = useState('');
|
||||
|
||||
const { data: nodesData } = useNodes(serverId);
|
||||
const nodes = nodesData?.nodes ?? [];
|
||||
// Default node to the first one.
|
||||
const node = nodePick || nodes[0]?.node || '';
|
||||
const { data: available = [], isFetching } = useAvailableLXCTemplates(serverId || undefined, node || undefined);
|
||||
const { data: storagesRaw = [] } = useStorages(serverId, node);
|
||||
const download = useDownloadLXCTemplate(serverId);
|
||||
@@ -112,9 +120,6 @@ export function DownloadCTDialog({ open, onClose }: { open: boolean; onClose: ()
|
||||
[storagesRaw],
|
||||
);
|
||||
|
||||
// Default node to the first one.
|
||||
useEffect(() => { if (!node && nodes.length) setNode(nodes[0].node); }, [nodes, node]);
|
||||
|
||||
const submit = () => {
|
||||
if (!node || !storage || !template) { toast.error(t('templates.download_fields_required')); return; }
|
||||
download.mutate({ node, storage, template }, {
|
||||
@@ -189,9 +194,11 @@ export function EditTemplateDialog({ template, onClose }: { template: OSTemplate
|
||||
const update = useUpdateTemplate();
|
||||
const [groupId, setGroupId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (template) setGroupId(String(template.group_id));
|
||||
}, [template]);
|
||||
const [filledFor, setFilledFor] = useState<OSTemplate | null>(null);
|
||||
if (template && template !== filledFor) {
|
||||
setFilledFor(template);
|
||||
setGroupId(String(template.group_id));
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
if (!template || !groupId) return;
|
||||
|
||||
@@ -116,7 +116,7 @@ function UsersTab() {
|
||||
try {
|
||||
await impersonate(u.id);
|
||||
toast.success(t('users.impersonate_started', { name: u.username }));
|
||||
navigate('/dashboard');
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
toast.error(getErrMsg(e));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } 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';
|
||||
@@ -146,13 +146,13 @@ function EditWorkspaceDialog({
|
||||
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]);
|
||||
// Populate selections once per loaded workspace (refetches keep the user's edits).
|
||||
const [populatedFor, setPopulatedFor] = useState<number | null>(null);
|
||||
if (workspace && workspace.id !== populatedFor) {
|
||||
setPopulatedFor(workspace.id);
|
||||
setSelectedServerIds(workspace.servers?.map((s) => s.id) ?? []);
|
||||
setSelectedUserIds(workspace.users?.map((u) => u.id) ?? []);
|
||||
}
|
||||
|
||||
const handleOpen = (v: boolean) => {
|
||||
if (v) {
|
||||
|
||||
@@ -152,21 +152,24 @@ export function useVMLiveMetrics(
|
||||
type: string,
|
||||
node: string,
|
||||
): VMLiveStatus | null {
|
||||
const [live, setLive] = useState<VMLiveStatus | null>(null);
|
||||
const channel = `instance_metrics:${serverId}_${vmid}_${type}_${node}`;
|
||||
// Tagged with its channel: the detail page stays mounted when the route
|
||||
// switches to another instance, and an untagged sample kept showing the
|
||||
// previous instance's metrics until the new channel's first message.
|
||||
const [live, setLive] = useState<{ channel: string; data: VMLiveStatus } | null>(null);
|
||||
const { data: http } = useVMStatus(serverId, vmid, type, node);
|
||||
|
||||
useEffect(() => {
|
||||
const ws = getTasksWebSocket();
|
||||
ws.connect();
|
||||
const channel = `instance_metrics:${serverId}_${vmid}_${type}_${node}`;
|
||||
const unsub = ws.subscribe(channel, (msg: unknown) => {
|
||||
const payload = msg as { data?: VMLiveStatus };
|
||||
if (payload?.data) setLive(payload.data);
|
||||
if (payload?.data) setLive({ channel, data: payload.data });
|
||||
});
|
||||
return () => unsub();
|
||||
}, [serverId, vmid, type, node]);
|
||||
}, [channel]);
|
||||
|
||||
return live ?? (http as VMLiveStatus) ?? null;
|
||||
return (live?.channel === channel ? live.data : null) ?? (http as VMLiveStatus) ?? null;
|
||||
}
|
||||
|
||||
export function useVMConfig(serverId: number, vmid: number, type: string, node: string, enabled = true) {
|
||||
@@ -388,21 +391,19 @@ export function useExecuteScript(serverId: number, vmid: number) {
|
||||
fd.set('script', script);
|
||||
fd.set('interpreter', interpreter);
|
||||
const qs = new URLSearchParams({ node, timeout: String(timeout) });
|
||||
const res = await fetch(`/proxmox/api/${serverId}/vm/${vmid}/execute-script?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiClient.getToken()}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const e = await res.json().catch(() => ({ detail: `HTTP ${res.status}` }));
|
||||
throw new Error((e as { detail?: string }).detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<ExecuteScriptResult>;
|
||||
// postForm, not a bare fetch: it adds X-Active-Workspace, handles an
|
||||
// expired session (401) and turns 422 detail arrays into readable text.
|
||||
return apiClient.postForm<ExecuteScriptResult>(`/proxmox/api/${serverId}/vm/${vmid}/execute-script?${qs}`, fd);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Power Actions ====================
|
||||
// reset/resume leave the guest running; treating them as "stopped" flashed the
|
||||
// row as stopped and kept polling for a state that never came.
|
||||
const RUNNING_AFTER = new Set(['start', 'restart', 'reboot', 'reset', 'resume']);
|
||||
const expectedStatusAfter = (action: string) => (RUNNING_AFTER.has(action) ? 'running' : 'stopped');
|
||||
|
||||
interface PowerActionResult {
|
||||
status: string;
|
||||
action: string;
|
||||
@@ -423,7 +424,7 @@ export function usePowerAction(serverId: number, vmid: number, type: string, nod
|
||||
onMutate: async ({ action }) => {
|
||||
await qc.cancelQueries({ queryKey: vmKeys.all });
|
||||
const previousVMs = qc.getQueryData<VMInstance[]>(vmKeys.all);
|
||||
const expectedStatus = action === 'start' || action === 'restart' ? 'running' : 'stopped';
|
||||
const expectedStatus = expectedStatusAfter(action);
|
||||
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
|
||||
old?.map((vm) =>
|
||||
vm.server_id === serverId && vm.vmid === vmid
|
||||
@@ -439,7 +440,7 @@ export function usePowerAction(serverId: number, vmid: number, type: string, nod
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, { action }) => {
|
||||
const expectedStatus = action === 'start' || action === 'restart' ? 'running' : 'stopped';
|
||||
const expectedStatus = expectedStatusAfter(action);
|
||||
let attempts = 0;
|
||||
const maxAttempts = 12; // 12 * 3s = 36s max
|
||||
const poll = () => {
|
||||
|
||||
@@ -197,7 +197,7 @@ function ImpersonationBanner() {
|
||||
try {
|
||||
await stopImpersonate();
|
||||
toast.success(t('users.impersonate_stopped'));
|
||||
navigate('/dashboard');
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import AppLayout from '@/layouts/AppLayout';
|
||||
import AuthLayout from '@/layouts/AuthLayout';
|
||||
import ConsoleLayout from '@/layouts/ConsoleLayout';
|
||||
import { ProtectedRoute, PublicRoute, RequirePermission } from '@/components/shared/route-guards';
|
||||
import { HomeRedirect, ProtectedRoute, PublicRoute, RequirePermission } from '@/components/shared/route-guards';
|
||||
import LoginPage from '@/features/auth/LoginPage';
|
||||
const ForgotPasswordPage = lazy(() => import('./features/auth/ForgotPasswordPage.tsx'));
|
||||
const ResetPasswordPage = lazy(() => import('./features/auth/ResetPasswordPage.tsx'));
|
||||
@@ -97,7 +97,7 @@ export const router = createBrowserRouter([
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/', element: <HomeRedirect /> },
|
||||
{ path: '/dashboard', element: <Page perm="dashboard:view"><DashboardPage /></Page> },
|
||||
{ path: '/instances', element: <Page perm="vm:view"><InstancesPage /></Page> },
|
||||
{ path: '/instances/create', element: <Page perm="vm:create"><CreateInstanceWizard /></Page> },
|
||||
@@ -132,5 +132,5 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
@@ -3,8 +3,24 @@ import { apiClient } from '@/lib/api-client';
|
||||
import { queryClient } from '@/lib/query-client';
|
||||
import { resetTasksWebSocket } from '@/lib/websocket';
|
||||
import { useWorkspaceStore } from '@/stores/workspace-store';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
import { useMinimizedOpsStore } from '@/stores/minimized-ops-store';
|
||||
import { useBulkTasksStore } from '@/stores/bulk-tasks-store';
|
||||
import type { User, LoginRequest, AuthResponse } from '@/types';
|
||||
|
||||
const LOAD_USER_RETRY_MS = 3_000;
|
||||
|
||||
// Per-identity client state that outlives the React Query cache: the active
|
||||
// workspace (sent as X-Active-Workspace) and the task trays, two of which are
|
||||
// persisted in localStorage. Left in place, the next identity in this browser
|
||||
// saw the previous one's task names and sent its workspace id.
|
||||
function resetIdentityState(): void {
|
||||
useWorkspaceStore.getState().setActiveWorkspace(null);
|
||||
useDeployTasksStore.setState({ tasks: [] });
|
||||
useMinimizedOpsStore.setState({ items: [] });
|
||||
useBulkTasksStore.setState({ tasks: [] });
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
@@ -30,15 +46,14 @@ async function switchToken(
|
||||
apiClient.setToken(token);
|
||||
queryClient.clear();
|
||||
resetTasksWebSocket(true);
|
||||
// Reset the active workspace: the previous identity's selection (sent via the
|
||||
// X-Active-Workspace header) may not belong to the new one.
|
||||
useWorkspaceStore.getState().setActiveWorkspace(null);
|
||||
// The previous identity's workspace selection may not belong to the new one.
|
||||
resetIdentityState();
|
||||
set({ token, isAuthenticated: true });
|
||||
const user = await apiClient.get<User>('/api/auth/me');
|
||||
set({ user });
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('access_token'),
|
||||
isAuthenticated: !!localStorage.getItem('access_token'),
|
||||
@@ -85,6 +100,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
// пользователю, который войдёт в этой же вкладке.
|
||||
queryClient.clear();
|
||||
resetTasksWebSocket(false);
|
||||
resetIdentityState();
|
||||
set({ user: null, token: null, isAuthenticated: false });
|
||||
},
|
||||
|
||||
@@ -99,9 +115,21 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
apiClient.setToken(token);
|
||||
const user = await apiClient.get<User>('/api/auth/me');
|
||||
set({ user, isAuthenticated: true, isLoading: false });
|
||||
} catch {
|
||||
apiClient.setToken(null);
|
||||
set({ user: null, token: null, isAuthenticated: false, isLoading: false });
|
||||
} catch (err) {
|
||||
const status = (err as { status?: number }).status;
|
||||
const rejected = status === 401 || status === 403
|
||||
|| (err instanceof Error && err.message === 'Unauthorized');
|
||||
if (rejected) {
|
||||
apiClient.setToken(null);
|
||||
set({ user: null, token: null, isAuthenticated: false, isLoading: false });
|
||||
return;
|
||||
}
|
||||
// Network error / 5xx (panel restarting, e.g. self-update): the session is
|
||||
// still valid — dropping the token here logged everybody out on a reload
|
||||
// during a restart. Keep the spinner up and try again.
|
||||
setTimeout(() => {
|
||||
if (localStorage.getItem('access_token')) get().loadUser();
|
||||
}, LOAD_USER_RETRY_MS);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user