2 Commits

Author SHA1 Message Date
markmorado
32434e0ef3 refactor(frontend): drop setState-in-effect patterns
Some checks failed
Deploy to Production / backend-tests (push) Has been cancelled
Deploy to Production / frontend-build (push) Has been cancelled
Frontend Checks / Build & typecheck (push) Has been cancelled
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled
Replace all 57 react-hooks/set-state-in-effect sites with the patterns
React recommends:

- dialog forms reset while rendering when the dialog opens, instead of
  in an effect after a frame with stale values;
- forms filled from server data resync during render when a new
  response object arrives;
- default selections (server, node, storage, disk, user) are derived
  from the user's pick and the current list rather than written back
  into state.

This also fixes the migrate dialogs resetting the chosen target when
the guest status refreshed, and the image download dialog resetting
"convert to template" on every render while storages were loading.
Unstable `?? []` / `new Set()` hook dependencies are memoized.
2026-10-23 01:51:57 +05:00
markmorado
990ca14c63 fix(frontend): routing, session state, console and power-action bugs
- Land users on their first accessible page instead of /dashboard, so
  users without dashboard:view no longer get an "access denied" toast
  on every sign-in, impersonation switch or unknown URL.
- Reset the active workspace and the persisted deploy/minimized/bulk
  task trays on logout and impersonation, so the next identity in the
  browser does not see the previous one's tasks or workspace.
- Keep the session when /api/auth/me fails with a network error or 5xx
  (panel restarting) and retry, instead of dropping the token.
- Notifications: mark-read/delete buttons no longer close the menu.
- Command palette starts every opening with an empty query.
- Live instance metrics no longer show the previous instance's data
  after switching instances.
- reset/resume are expected to leave the guest running (no "stopped"
  flash and no 36 s of pointless polling).
- Execute-script goes through apiClient (workspace header, 401 handling,
  readable validation errors).
- Node shell no longer reconnects (re-running apt dist-upgrade) when
  the translation function changes; fullscreen icon follows Esc exits.
- Node power/certificate actions use hasPermission so role-bound admins
  see them; power toasts are translated.
2026-10-23 01:51:53 +05:00
47 changed files with 517 additions and 378 deletions

View File

@@ -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);

View File

@@ -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'}`}>

View File

@@ -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 />;

View File

@@ -25,7 +25,7 @@ export default function LoginPage() {
setTwoFactorRequired(true);
return;
}
navigate('/dashboard');
navigate('/');
} catch {
// Error is already handled in store
}

View File

@@ -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 => {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 ${

View File

@@ -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 ${

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 }));

View File

@@ -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', 'Выберите сетевой мост');

View File

@@ -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 (без .функции)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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}>

View File

@@ -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);
}
}}

View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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]
);

View File

@@ -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(

View File

@@ -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;

View File

@@ -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 : '';

View File

@@ -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 ?? [];

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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(

View File

@@ -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);

View File

@@ -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);

View File

@@ -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),

View File

@@ -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 }));

View File

@@ -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;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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({

View File

@@ -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, {

View File

@@ -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;

View File

@@ -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));
}

View File

@@ -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) {

View File

@@ -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 = () => {

View File

@@ -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));
}

View File

@@ -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 /> },
]);

View File

@@ -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);
}
},