Compare commits

2 Commits

Author SHA1 Message Date
fbf8232ce3 feat: update station names 2026-04-09 20:07:18 +03:00
8d1de769c5 feat: add snapshot memory and blocked create button 2026-04-09 18:50:40 +03:00
14 changed files with 220 additions and 53 deletions

6
.env
View File

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

View File

@@ -99,7 +99,7 @@ export const LinkedItems = <
}}
>
<Typography variant="subtitle1" fontWeight="bold">
Привязанные станции
Привязанные остановки
</Typography>
</AccordionSummary>
@@ -499,7 +499,7 @@ const LinkedItemsContentsInner = <
{linkedItems.length === 0 && !isLoading && (
<Typography color="textSecondary" textAlign="center" py={2}>
Станции не найдены
Остановки не найдены
</Typography>
)}

View File

@@ -26,7 +26,7 @@ export function Widgets() {
justifyContent="center"
>
<Typography variant="h6" sx={{ color: "#fff" }}>
Станция
Остановка
</Typography>
</Stack>

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,7 +90,21 @@ export const SnapshotListPage = observer(() => {
return <div>{params.value ? params.value : "-"}</div>;
},
},
...(canManageSnapshots ? [{
{
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,
@@ -87,7 +130,9 @@ export const SnapshotListPage = observer(() => {
</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

@@ -73,7 +73,7 @@ export const StationCreatePage = observer(() => {
navigate("/station");
} catch (error) {
console.error("Error creating station:", error);
toast.error("Ошибка при создании станции");
toast.error("Ошибка при создании остановки");
} finally {
setIsLoading(false);
}

View File

@@ -81,7 +81,7 @@ export const StationEditPage = observer(() => {
toast.success("Остановка успешно обновлена");
} catch (error) {
console.error("Error updating station:", error);
toast.error("Ошибка при обновлении станции");
toast.error("Ошибка при обновлении остановки");
} finally {
setIsLoading(false);
}
@@ -192,7 +192,7 @@ export const StationEditPage = observer(() => {
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных станции..." />
<LoadingSpinner message="Загрузка данных остановки..." />
</Box>
);
}

View File

@@ -147,7 +147,7 @@ export const StationListPage = observer(() => {
<div className="w-full">
<div className="flex justify-between items-center mb-10">
<h1 className="text-2xl">Станции</h1>
<h1 className="text-2xl">Остановки</h1>
{canWriteStations && (
<CreateButton label="Создать остановки" path="/station/create" />
)}
@@ -222,7 +222,7 @@ export const StationListPage = observer(() => {
slots={{
noRowsOverlay: () => (
<Box sx={{ mt: 5, textAlign: "center", color: "text.secondary" }}>
{isLoading ? <CircularProgress size={20} /> : "Нет станций"}
{isLoading ? <CircularProgress size={20} /> : "Нет остановок"}
</Box>
),
}}

View File

@@ -39,7 +39,7 @@ export const StationPreviewPage = observer(() => {
minHeight: "60vh",
}}
>
<LoadingSpinner message="Загрузка данных станции..." />
<LoadingSpinner message="Загрузка данных остановки..." />
</Box>
);
}

View File

@@ -97,7 +97,6 @@ class RouteStore {
saveRouteStations = async (routeId: number, stationId: number) => {
const { language } = languageStore;
// Получаем актуальные данные станции с сервера
const stationResponse = await languageInstance(language).get(
`/station/${stationId}`
);
@@ -108,7 +107,6 @@ class RouteStore {
(station) => station.id === stationId
);
// Формируем данные для отправки: все поля станции + отредактированные offset
const dataToSend: any = {
station_id: stationId,
offset_x: editedStationData?.offset_x ?? fullStationData.offset_x ?? 0,

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

@@ -555,7 +555,6 @@ class StationsStore {
) => {
const { language } = languageStore;
// Получаем данные станции для текущего языка
const response = await languageInstance(language).get(`/station/${id}`);
const stationData = response.data as Station;

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>

View File

@@ -117,7 +117,7 @@ export const EditStationTransfersModal = observer(
</IconButton>
<Box>
<Typography variant="caption" color="text.secondary">
Станции / Редактировать пересадки
Остановки / Редактировать пересадки
</Typography>
<Typography variant="h6">Редактирование пересадок</Typography>
</Box>