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

View File

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

View File

@@ -5,13 +5,35 @@ import { useEffect, useState, useMemo } from "react";
import { observer } from "mobx-react-lite";
import { DatabaseBackup, Trash2 } from "lucide-react";
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(() => {
const { snapshots, getSnapshots, deleteSnapshot, restoreSnapshot } =
snapshotStore;
const {
snapshots,
getSnapshots,
deleteSnapshot,
restoreSnapshot,
storageInfo,
getStorageInfo,
} = snapshotStore;
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 [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -25,10 +47,17 @@ export const SnapshotListPage = observer(() => {
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(() => {
const fetchSnapshots = async () => {
setIsLoading(true);
await getSnapshots();
await Promise.all([getSnapshots(), getStorageInfo()]);
setIsLoading(false);
};
fetchSnapshots();
@@ -61,33 +90,49 @@ export const SnapshotListPage = observer(() => {
return <div>{params.value ? params.value : "-"}</div>;
},
},
...(canManageSnapshots ? [{
field: "actions",
headerName: "Действия",
width: 300,
headerAlign: "center" as const,
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={() => {
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>
),
}] : []),
{
field: "occupied_memory",
headerName: "Размер",
width: 120,
renderCell: (params: GridRenderCellParams) => {
return (
<div>
{params.value != null ? `${params.value.toFixed(1)} ГБ` : "-"}
</div>
);
},
},
...(canManageSnapshots
? [
{
field: "actions",
headerName: "Действия",
width: 300,
headerAlign: "center" as const,
sortable: false,
renderCell: (params: GridRenderCellParams) => (
<div className="flex h-full gap-7 justify-center items-center">
<button
onClick={() => {
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(() => {
@@ -97,16 +142,25 @@ export const SnapshotListPage = observer(() => {
(snapshot) =>
!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) => ({
id: snapshot.ID,
name: snapshot.Name,
parent: snapshots.find((s) => s.ID === snapshot.ParentID)?.Name,
created_at: formatCreationTime(snapshot.CreationTime),
occupied_memory: snapshot.occupied_memory,
}));
}, [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 (
<>
<div style={{ width: "100%" }}>
@@ -114,9 +168,100 @@ export const SnapshotListPage = observer(() => {
<h1 className="text-2xl ">Экспорт Медиа</h1>
{canCreateSnapshot && (
<CreateButton label="Создать экспорт медиа" path="/snapshot/create" />
<CreateButton
label="Создать экспорт медиа"
path="/snapshot/create"
disabled={isLowStorage}
/>
)}
</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} />
<DataGrid

View File

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

View File

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