feat: add snapshot memory and blocked create button

This commit is contained in:
2026-04-09 18:50:40 +03:00
parent 4b02c6e9d3
commit 8d1de769c5
5 changed files with 210 additions and 40 deletions

6
.env
View File

@@ -1,6 +1,6 @@
# # VITE_API_URL='https://wn.st.unprism.ru' # VITE_API_URL='https://wn.st.unprism.ru'
# # VITE_REACT_APP ='https://wn.st.unprism.ru/' # VITE_REACT_APP ='https://wn.st.unprism.ru/'
# # VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/' # VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/'
# VITE_NEED_AUTH='true' # VITE_NEED_AUTH='true'
VITE_API_URL='https://wn.krbl.ru' VITE_API_URL='https://wn.krbl.ru'
VITE_REACT_APP ='https://wn.krbl.ru/' VITE_REACT_APP ='https://wn.krbl.ru/'

View File

@@ -8,7 +8,7 @@ import { toast } from "react-toastify";
import { runInAction } from "mobx"; import { runInAction } from "mobx";
export const SnapshotCreatePage = observer(() => { export const SnapshotCreatePage = observer(() => {
const { createSnapshot, getSnapshotStatus, snapshotStatus } = snapshotStore; const { createSnapshot, getSnapshotStatus, getStorageInfo, snapshotStatus } = snapshotStore;
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -59,6 +59,7 @@ export const SnapshotCreatePage = observer(() => {
snapshotStore.snapshotStatus = null; snapshotStore.snapshotStatus = null;
}); });
await getStorageInfo();
navigate(-1); navigate(-1);
} }
} catch (error) { } catch (error) {

View File

@@ -5,13 +5,35 @@ import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react"; import { DatabaseBackup, Trash2 } from "lucide-react";
import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets"; import { CreateButton, DeleteModal, SnapshotRestore } from "@widgets";
import { Box, CircularProgress } from "@mui/material"; import { Alert, Box, CircularProgress } from "@mui/material";
const LOW_STORAGE_THRESHOLD_GB = 10;
const SEGMENT_COLORS = [
"#FF3B30",
"#FF9500",
"#FFCC00",
"#8E8E93",
"#AEAEB2",
"#34C759",
"#007AFF",
"#5856D6",
"#AF52DE",
"#FF2D55",
];
export const SnapshotListPage = observer(() => { export const SnapshotListPage = observer(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } = const {
snapshotStore; snapshots,
getSnapshots,
deleteSnapshot,
restoreSnapshot,
storageInfo,
getStorageInfo,
} = snapshotStore;
const canWriteDevices = authStore.canWrite("devices"); const canWriteDevices = authStore.canWrite("devices");
const canCreateSnapshot = authStore.hasRole("snapshot_create") && canWriteDevices; const canCreateSnapshot =
authStore.hasRole("snapshot_create") && canWriteDevices;
const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices; const canManageSnapshots = authStore.canWrite("snapshot") && canWriteDevices;
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -25,10 +47,17 @@ export const SnapshotListPage = observer(() => {
pageSize: 50, pageSize: 50,
}); });
const availableGB = storageInfo ? storageInfo.available_memory : null;
const totalGB = storageInfo ? storageInfo.all_memory : null;
const usedGB =
totalGB !== null && availableGB !== null ? totalGB - availableGB : null;
const isLowStorage =
availableGB !== null && availableGB < LOW_STORAGE_THRESHOLD_GB;
useEffect(() => { useEffect(() => {
const fetchSnapshots = async () => { const fetchSnapshots = async () => {
setIsLoading(true); setIsLoading(true);
await getSnapshots(); await Promise.all([getSnapshots(), getStorageInfo()]);
setIsLoading(false); setIsLoading(false);
}; };
fetchSnapshots(); fetchSnapshots();
@@ -61,33 +90,49 @@ export const SnapshotListPage = observer(() => {
return <div>{params.value ? params.value : "-"}</div>; return <div>{params.value ? params.value : "-"}</div>;
}, },
}, },
...(canManageSnapshots ? [{ {
field: "actions", field: "occupied_memory",
headerName: "Действия", headerName: "Размер",
width: 300, width: 120,
headerAlign: "center" as const, renderCell: (params: GridRenderCellParams) => {
sortable: false, return (
renderCell: (params: GridRenderCellParams) => ( <div>
<div className="flex h-full gap-7 justify-center items-center"> {params.value != null ? `${params.value.toFixed(1)} ГБ` : "-"}
<button </div>
onClick={() => { );
setIsRestoreModalOpen(true); },
setRowId(params.row.id); },
}} ...(canManageSnapshots
> ? [
<DatabaseBackup size={20} className="text-blue-500" /> {
</button> field: "actions",
<button headerName: "Действия",
onClick={() => { width: 300,
setIsDeleteModalOpen(true); headerAlign: "center" as const,
setRowId(params.row.id); sortable: false,
}} renderCell: (params: GridRenderCellParams) => (
> <div className="flex h-full gap-7 justify-center items-center">
<Trash2 size={20} className="text-red-500" /> <button
</button> onClick={() => {
</div> setIsRestoreModalOpen(true);
), setRowId(params.row.id);
}] : []), }}
>
<DatabaseBackup size={20} className="text-blue-500" />
</button>
<button
onClick={() => {
setIsDeleteModalOpen(true);
setRowId(params.row.id);
}}
>
<Trash2 size={20} className="text-red-500" />
</button>
</div>
),
},
]
: []),
]; ];
const rows = useMemo(() => { const rows = useMemo(() => {
@@ -97,16 +142,25 @@ export const SnapshotListPage = observer(() => {
(snapshot) => (snapshot) =>
!query || !query ||
(snapshot.Name ?? "").toLowerCase().includes(query) || (snapshot.Name ?? "").toLowerCase().includes(query) ||
(snapshots.find((s) => s.ID === snapshot.ParentID)?.Name ?? "").toLowerCase().includes(query) (snapshots.find((s) => s.ID === snapshot.ParentID)?.Name ?? "")
.toLowerCase()
.includes(query),
) )
.map((snapshot) => ({ .map((snapshot) => ({
id: snapshot.ID, id: snapshot.ID,
name: snapshot.Name, name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name, parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime), created_at: formatCreationTime(snapshot.CreationTime),
occupied_memory: snapshot.occupied_memory,
})); }));
}, [snapshots, searchQuery]); }, [snapshots, searchQuery]);
const snapshotsGB = rows.reduce(
(sum, row) => sum + (row.occupied_memory ?? 0),
0,
);
const systemGB = usedGB !== null ? Math.max(0, usedGB - snapshotsGB) : null;
return ( return (
<> <>
<div style={{ width: "100%" }}> <div style={{ width: "100%" }}>
@@ -114,9 +168,100 @@ export const SnapshotListPage = observer(() => {
<h1 className="text-2xl ">Экспорт Медиа</h1> <h1 className="text-2xl ">Экспорт Медиа</h1>
{canCreateSnapshot && ( {canCreateSnapshot && (
<CreateButton label="Создать экспорт медиа" path="/snapshot/create" /> <CreateButton
label="Создать экспорт медиа"
path="/snapshot/create"
disabled={isLowStorage}
/>
)} )}
</div> </div>
{usedGB !== null && totalGB !== null && (
<div className="bg-white rounded-2xl p-5 mb-6 shadow-sm border border-gray-100">
<div className="flex items-baseline gap-3 mb-3">
<span className="text-lg font-semibold">Хранилище</span>
<span className="text-sm text-gray-500">
Используется: {usedGB.toFixed(2)} ГБ из {totalGB.toFixed(0)} ГБ
</span>
</div>
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
{rows.map((row, i) => {
const pct =
row.occupied_memory != null && totalGB > 0
? (row.occupied_memory / totalGB) * 100
: 0;
if (pct <= 0) return null;
return (
<div
key={row.id}
style={{
width: `${pct}%`,
backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
title={`${row.name}: ${row.occupied_memory?.toFixed(1)} ГБ`}
/>
);
})}
{systemGB !== null && systemGB > 0 && totalGB > 0 && (
<div
style={{
width: `${(systemGB / totalGB) * 100}%`,
backgroundColor: "#C7C7CC",
}}
title={`Системные данные: ${systemGB.toFixed(1)} ГБ`}
/>
)}
</div>
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
{rows.map((row, i) => {
if (row.occupied_memory == null || row.occupied_memory <= 0)
return null;
return (
<div
key={row.id}
className="flex items-center gap-1.5 text-xs text-gray-700"
>
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{
backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}}
/>
{row.name}
</div>
);
})}
{systemGB !== null && systemGB > 0 && (
<div className="flex items-center gap-1.5 text-xs text-gray-700">
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: "#C7C7CC" }}
/>
Системные данные
</div>
)}
{availableGB !== null && availableGB > 0 && (
<div className="flex items-center gap-1.5 text-xs text-gray-700">
<span className="inline-block w-2.5 h-2.5 rounded-full bg-gray-100" />
Свободно
</div>
)}
</div>
</div>
)}
{isLowStorage && (
<Alert severity="warning" className="mb-4">
Недостаточно места на диске! Осталось {availableGB?.toFixed(1)} ГБ
из {totalGB?.toFixed(0)} ГБ. Создание новых экспортов заблокировано.
Удалите ненужные экспорты для освобождения места или обратитесь к
администратору сервера.
</Alert>
)}
<SearchInput value={searchQuery} onChange={setSearchQuery} /> <SearchInput value={searchQuery} onChange={setSearchQuery} />
<DataGrid <DataGrid

View File

@@ -23,6 +23,7 @@ type Snapshot = {
Name: string; Name: string;
ParentID: string; ParentID: string;
CreationTime: string; CreationTime: string;
occupied_memory: number;
}; };
type SnapshotStatus = { type SnapshotStatus = {
@@ -32,11 +33,17 @@ type SnapshotStatus = {
Error: string; Error: string;
}; };
type StorageInfo = {
available_memory: number;
all_memory: number;
};
class SnapshotStore { class SnapshotStore {
snapshots: Snapshot[] = []; snapshots: Snapshot[] = [];
snapshot: Snapshot | null = null; snapshot: Snapshot | null = null;
lastRequestId: string | null = null; lastRequestId: string | null = null;
snapshotStatus: SnapshotStatus | null = null; snapshotStatus: SnapshotStatus | null = null;
storageInfo: StorageInfo | null = null;
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
@@ -259,10 +266,17 @@ class SnapshotStore {
}; };
deleteSnapshot = async (id: string) => { deleteSnapshot = async (id: string) => {
const snapshot = this.snapshots.find((s) => s.ID === id);
await authInstance.delete(`/snapshots/${id}`); await authInstance.delete(`/snapshots/${id}`);
runInAction(() => { runInAction(() => {
this.snapshots = this.snapshots.filter((snapshot) => snapshot.ID !== id); this.snapshots = this.snapshots.filter((s) => s.ID !== id);
if (this.storageInfo && snapshot?.occupied_memory) {
this.storageInfo = {
...this.storageInfo,
available_memory: this.storageInfo.available_memory + snapshot.occupied_memory,
};
}
}); });
}; };
@@ -299,6 +313,14 @@ class SnapshotStore {
this.snapshotStatus = response.data; this.snapshotStatus = response.data;
}); });
}; };
getStorageInfo = async () => {
const response = await authInstance.get(`/snapshots/storage`);
runInAction(() => {
this.storageInfo = response.data;
});
};
} }
export const snapshotStore = new SnapshotStore(); export const snapshotStore = new SnapshotStore();

View File

@@ -5,9 +5,10 @@ import { useNavigate } from "react-router-dom";
interface CreateButtonProps { interface CreateButtonProps {
label: string; label: string;
path: string; path: string;
disabled?: boolean;
} }
export const CreateButton = ({ label, path }: CreateButtonProps) => { export const CreateButton = ({ label, path, disabled }: CreateButtonProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
@@ -18,6 +19,7 @@ export const CreateButton = ({ label, path }: CreateButtonProps) => {
navigate(path); navigate(path);
}} }}
startIcon={<Plus size={20} />} startIcon={<Plus size={20} />}
disabled={disabled}
> >
{label} {label}
</Button> </Button>