feat: testing mode banner + snapshot storage fields fix

This commit is contained in:
2026-04-17 15:31:14 +03:00
parent d380b2570f
commit a3a4d2eb18
17 changed files with 228 additions and 29 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "white-nights", "name": "white-nights",
"private": true, "private": true,
"version": "1.0.5", "version": "1.0.6",
"type": "module", "type": "module",
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {

View File

@@ -5,10 +5,12 @@ import { CustomTheme } from "@shared";
import { ThemeProvider } from "@mui/material/styles"; import { ThemeProvider } from "@mui/material/styles";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { GlobalErrorBoundary } from "./GlobalErrorBoundary"; import { GlobalErrorBoundary } from "./GlobalErrorBoundary";
import { TestingModeBanner } from "@widgets";
export const App: React.FC = () => ( export const App: React.FC = () => (
<GlobalErrorBoundary> <GlobalErrorBoundary>
<ThemeProvider theme={CustomTheme.Light}> <ThemeProvider theme={CustomTheme.Light}>
<TestingModeBanner />
<ToastContainer /> <ToastContainer />
<Router /> <Router />
</ThemeProvider> </ThemeProvider>

View File

@@ -6,6 +6,7 @@ import { observer } from "mobx-react-lite";
export const PreviewLeftWidget = observer(() => { export const PreviewLeftWidget = observer(() => {
const { articleMedia, articleData } = articlesStore; const { articleMedia, articleData } = articlesStore;
const { language } = languageStore; const { language } = languageStore;
const body = articleData?.[language]?.body;
return ( return (
<Paper <Paper
@@ -66,7 +67,7 @@ export const PreviewLeftWidget = observer(() => {
{articleData?.[language]?.heading || "Название информации"} {articleData?.[language]?.heading || "Название информации"}
</Typography> </Typography>
</Box> </Box>
{articleData?.[language]?.body && ( {body && (
<Box <Box
sx={{ sx={{
padding: 1, padding: 1,
@@ -77,7 +78,7 @@ export const PreviewLeftWidget = observer(() => {
flexGrow: 1, flexGrow: 1,
}} }}
> >
<ReactMarkdownComponent value={articleData?.[language]?.body} /> <ReactMarkdownComponent value={body} />
</Box> </Box>
)} )}
</Paper> </Paper>

View File

@@ -1,6 +1,5 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
@@ -52,6 +51,7 @@ export const RouteCreatePage = observer(() => {
const [turn, setTurn] = useState(""); const [turn, setTurn] = useState("");
const [centerLat, setCenterLat] = useState(""); const [centerLat, setCenterLat] = useState("");
const [centerLng, setCenterLng] = useState(""); const [centerLng, setCenterLng] = useState("");
const [videoTimer, setVideoTimer] = useState(60);
const [videoPreview, setVideoPreview] = useState<string>(""); const [videoPreview, setVideoPreview] = useState<string>("");
const [icon, setIcon] = useState<string>(""); const [icon, setIcon] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -279,6 +279,7 @@ export const RouteCreatePage = observer(() => {
path, path,
video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined, video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined,
icon: !isMediaIdEmpty(icon) ? icon : undefined, icon: !isMediaIdEmpty(icon) ? icon : undefined,
video_timer: videoTimer,
}; };
if (governor_appeal !== undefined) { if (governor_appeal !== undefined) {
@@ -301,7 +302,7 @@ export const RouteCreatePage = observer(() => {
); );
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <div className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -375,6 +376,7 @@ export const RouteCreatePage = observer(() => {
} }
placeholder="55.7558 37.6173&#10;55.7539 37.6208" placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{ sx={{
mt: 1,
"& .MuiInputBase-root": { "& .MuiInputBase-root": {
maxHeight: "500px", maxHeight: "500px",
overflow: "auto", overflow: "auto",
@@ -383,7 +385,6 @@ export const RouteCreatePage = observer(() => {
fontFamily: "monospace", fontFamily: "monospace",
fontSize: "0.8rem", fontSize: "0.8rem",
lineHeight: "1.2", lineHeight: "1.2",
padding: "8px 12px",
}, },
"& .MuiFormHelperText-root": { "& .MuiFormHelperText-root": {
fontSize: "0.75rem", fontSize: "0.75rem",
@@ -552,6 +553,18 @@ export const RouteCreatePage = observer(() => {
value={centerLng} value={centerLng}
onChange={(e) => setCenterLng(e.target.value)} onChange={(e) => setCenterLng(e.target.value)}
/> />
<TextField
className="w-full"
label="Таймер видео (сек)"
type="number"
value={videoTimer}
onChange={(e) => {
const val = Math.max(1, Math.round(Number(e.target.value)));
if (Number.isFinite(val)) {
setVideoTimer(val);
}
}}
/>
</Box> </Box>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button
@@ -641,6 +654,6 @@ export const RouteCreatePage = observer(() => {
onClose={() => setIsPreviewIconOpen(false)} onClose={() => setIsPreviewIconOpen(false)}
mediaId={previewIconId} mediaId={previewIconId}
/> />
</Paper> </div>
); );
}); });

View File

@@ -1,6 +1,5 @@
import { import {
Button, Button,
Paper,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
@@ -293,7 +292,7 @@ export const RouteEditPage = observer(() => {
} }
return ( return (
<Paper className="w-full h-full p-3 flex flex-col gap-10"> <div className="w-full h-full p-3 flex flex-col gap-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
className="flex items-center gap-2" className="flex items-center gap-2"
@@ -403,6 +402,7 @@ export const RouteEditPage = observer(() => {
} }
placeholder="55.7558 37.6173&#10;55.7539 37.6208" placeholder="55.7558 37.6173&#10;55.7539 37.6208"
sx={{ sx={{
mt: 1,
"& .MuiInputBase-root": { "& .MuiInputBase-root": {
maxHeight: "500px", maxHeight: "500px",
overflow: "auto", overflow: "auto",
@@ -411,7 +411,6 @@ export const RouteEditPage = observer(() => {
fontFamily: "monospace", fontFamily: "monospace",
fontSize: "0.8rem", fontSize: "0.8rem",
lineHeight: "1.2", lineHeight: "1.2",
padding: "8px 12px",
}, },
"& .MuiFormHelperText-root": { "& .MuiFormHelperText-root": {
fontSize: "0.75rem", fontSize: "0.75rem",
@@ -547,6 +546,18 @@ export const RouteEditPage = observer(() => {
}) })
} }
/> />
<TextField
className="w-full"
label="Таймер видео (сек)"
type="number"
value={editRouteData.video_timer ?? 60}
onChange={(e) => {
const val = Math.max(1, Math.round(Number(e.target.value)));
if (Number.isFinite(val)) {
routeStore.setEditRouteData({ video_timer: val });
}
}}
/>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Обращение к пассажирам Обращение к пассажирам
@@ -743,6 +754,6 @@ export const RouteEditPage = observer(() => {
onCancel={() => setIsDeleteIconModalOpen(false)} onCancel={() => setIsDeleteIconModalOpen(false)}
edit edit
/> />
</Paper> </div>
); );
}); });

View File

@@ -47,8 +47,8 @@ export const SnapshotListPage = observer(() => {
pageSize: 50, pageSize: 50,
}); });
const availableGB = storageInfo ? storageInfo.available_memory : null; const availableGB = storageInfo ? storageInfo.available_disk_space_gb : null;
const totalGB = storageInfo ? storageInfo.all_memory : null; const totalGB = storageInfo ? storageInfo.total_disk_space_gb : null;
const usedGB = const usedGB =
totalGB !== null && availableGB !== null ? totalGB - availableGB : null; totalGB !== null && availableGB !== null ? totalGB - availableGB : null;
const isLowStorage = const isLowStorage =
@@ -91,7 +91,7 @@ export const SnapshotListPage = observer(() => {
}, },
}, },
{ {
field: "occupied_memory", field: "occupied_disk_space_gb",
headerName: "Размер", headerName: "Размер",
width: 120, width: 120,
renderCell: (params: GridRenderCellParams) => { renderCell: (params: GridRenderCellParams) => {
@@ -151,12 +151,12 @@ export const SnapshotListPage = observer(() => {
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, occupied_disk_space_gb: snapshot.occupied_disk_space_gb,
})); }));
}, [snapshots, searchQuery]); }, [snapshots, searchQuery]);
const snapshotsGB = rows.reduce( const snapshotsGB = rows.reduce(
(sum, row) => sum + (row.occupied_memory ?? 0), (sum, row) => sum + (row.occupied_disk_space_gb ?? 0),
0, 0,
); );
const systemGB = usedGB !== null ? Math.max(0, usedGB - snapshotsGB) : null; const systemGB = usedGB !== null ? Math.max(0, usedGB - snapshotsGB) : null;
@@ -175,7 +175,7 @@ export const SnapshotListPage = observer(() => {
/> />
)} )}
</div> </div>
{usedGB !== null && totalGB !== null && ( {usedGB != null && totalGB != null && (
<div className="bg-white rounded-2xl p-5 mb-6 shadow-sm border border-gray-100"> <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"> <div className="flex items-baseline gap-3 mb-3">
<span className="text-lg font-semibold">Хранилище</span> <span className="text-lg font-semibold">Хранилище</span>
@@ -187,8 +187,8 @@ export const SnapshotListPage = observer(() => {
<div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100"> <div className="flex w-full h-3 rounded-lg overflow-hidden bg-gray-100">
{rows.map((row, i) => { {rows.map((row, i) => {
const pct = const pct =
row.occupied_memory != null && totalGB > 0 row.occupied_disk_space_gb != null && totalGB > 0
? (row.occupied_memory / totalGB) * 100 ? (row.occupied_disk_space_gb / totalGB) * 100
: 0; : 0;
if (pct <= 0) return null; if (pct <= 0) return null;
return ( return (
@@ -199,7 +199,7 @@ export const SnapshotListPage = observer(() => {
backgroundColor: backgroundColor:
SEGMENT_COLORS[i % SEGMENT_COLORS.length], SEGMENT_COLORS[i % SEGMENT_COLORS.length],
}} }}
title={`${row.name}: ${row.occupied_memory?.toFixed(1)} ГБ`} title={`${row.name}: ${row.occupied_disk_space_gb?.toFixed(1)} ГБ`}
/> />
); );
})} })}
@@ -216,7 +216,7 @@ export const SnapshotListPage = observer(() => {
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-3"> <div className="flex flex-wrap gap-x-5 gap-y-1 mt-3">
{rows.map((row, i) => { {rows.map((row, i) => {
if (row.occupied_memory == null || row.occupied_memory <= 0) if (row.occupied_disk_space_gb == null || row.occupied_disk_space_gb <= 0)
return null; return null;
return ( return (
<div <div

View File

@@ -39,6 +39,7 @@ export type SightCommonInfo = {
left_article: number; left_article: number;
preview_media: string | null; preview_media: string | null;
video_preview: string | null; video_preview: string | null;
preview_font_size?: number;
}; };
export type SightBaseInfo = { export type SightBaseInfo = {

View File

@@ -23,6 +23,7 @@ export type Route = {
scale_max: number; scale_max: number;
scale_min: number; scale_min: number;
video_preview: string; video_preview: string;
video_timer: number;
}; };
class RouteStore { class RouteStore {
@@ -150,6 +151,7 @@ class RouteStore {
scale_max: 0, scale_max: 0,
scale_min: 0, scale_min: 0,
video_preview: "" as string | undefined, video_preview: "" as string | undefined,
video_timer: 60,
}; };
setEditRouteData = (data: any) => { setEditRouteData = (data: any) => {

View File

@@ -23,7 +23,7 @@ type Snapshot = {
Name: string; Name: string;
ParentID: string; ParentID: string;
CreationTime: string; CreationTime: string;
occupied_memory: number; occupied_disk_space_gb: number;
}; };
type SnapshotStatus = { type SnapshotStatus = {
@@ -34,8 +34,8 @@ type SnapshotStatus = {
}; };
type StorageInfo = { type StorageInfo = {
available_memory: number; available_disk_space_gb: number;
all_memory: number; total_disk_space_gb: number;
}; };
class SnapshotStore { class SnapshotStore {
@@ -271,10 +271,10 @@ class SnapshotStore {
runInAction(() => { runInAction(() => {
this.snapshots = this.snapshots.filter((s) => s.ID !== id); this.snapshots = this.snapshots.filter((s) => s.ID !== id);
if (this.storageInfo && snapshot?.occupied_memory) { if (this.storageInfo && snapshot?.occupied_disk_space_gb) {
this.storageInfo = { this.storageInfo = {
...this.storageInfo, ...this.storageInfo,
available_memory: this.storageInfo.available_memory + snapshot.occupied_memory, available_disk_space_gb: this.storageInfo.available_disk_space_gb + snapshot.occupied_disk_space_gb,
}; };
} }
}); });

View File

@@ -0,0 +1,18 @@
import { authInstance } from "@shared";
export type TestingModeResponse = {
enabled: boolean;
updated_at: string;
};
export const getTestingModeApi = async (): Promise<TestingModeResponse> => {
const response = await authInstance.get("/testing-mode");
return response.data as TestingModeResponse;
};
export const setTestingModeApi = async (request: {
enabled: boolean;
}): Promise<TestingModeResponse> => {
const response = await authInstance.post("/testing-mode", request);
return response.data as TestingModeResponse;
};

View File

@@ -0,0 +1,62 @@
import { makeAutoObservable } from "mobx";
import { mobxFetch } from "@shared";
import {
TestingModeResponse,
getTestingModeApi,
setTestingModeApi,
} from "./api";
const POLLING_INTERVAL = 10_000;
class TestingModeStore {
testingMode: TestingModeResponse | null = null;
testingModeLoading = false;
testingModeError: string | null = null;
setTestingModeResult: TestingModeResponse | null = null;
setTestingModeLoading = false;
setTestingModeError: string | null = null;
constructor() {
makeAutoObservable(this);
}
get isEnabled(): boolean {
return this.testingMode?.enabled ?? false;
}
fetchTestingModeAction = mobxFetch<TestingModeResponse, TestingModeStore>({
store: this,
value: "testingMode",
loading: "testingModeLoading",
error: "testingModeError",
fn: getTestingModeApi,
pollingInterval: POLLING_INTERVAL,
});
setTestingModeAction = mobxFetch<
{ enabled: boolean },
TestingModeResponse,
TestingModeStore
>({
store: this,
value: "setTestingModeResult",
loading: "setTestingModeLoading",
error: "setTestingModeError",
fn: setTestingModeApi,
onSuccess: (result) => {
this.testingMode = result;
},
});
startPolling() {
this.fetchTestingModeAction();
}
stopPolling() {
this.fetchTestingModeAction.stopPolling?.();
}
}
export const testingModeStore = new TestingModeStore();
export { TestingModeStore };

View File

@@ -16,3 +16,4 @@ export * from "./CarrierStore";
export * from "./StationsStore"; export * from "./StationsStore";
export * from "./MenuStore"; export * from "./MenuStore";
export * from "./SelectedCityStore"; export * from "./SelectedCityStore";
export * from "./TestingModeStore";

View File

@@ -33,6 +33,7 @@ interface SightFramePreviewProps {
previewMedia: PreviewMediaData | null; previewMedia: PreviewMediaData | null;
articles: Article[]; articles: Article[];
onArticleSelect: (index: number) => void; onArticleSelect: (index: number) => void;
previewFontSize?: number;
} }
// Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes // Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes
@@ -151,6 +152,7 @@ export function SightFramePreview({
previewMedia, previewMedia,
articles, articles,
onArticleSelect, onArticleSelect,
previewFontSize,
}: SightFramePreviewProps) { }: SightFramePreviewProps) {
const token = localStorage.getItem("token") ?? ""; const token = localStorage.getItem("token") ?? "";
@@ -235,7 +237,12 @@ export function SightFramePreview({
{/* title: intro-title (300px, 40px centered) or regular (24px with border) */} {/* title: intro-title (300px, 40px centered) or regular (24px with border) */}
<div <div
className={`sfp-sight-frame-title${isIntro ? " sfp-intro-title" : ""}`} className={`sfp-sight-frame-title${isIntro ? " sfp-intro-title" : ""}`}
style={{ lineHeight: isIntro ? undefined : titleLineHeight }} style={{
lineHeight: isIntro ? undefined : titleLineHeight,
...(isIntro && previewFontSize != null
? { fontSize: `${previewFontSize}px` }
: {}),
}}
> >
<p <p
style={{ style={{

View File

@@ -5,6 +5,8 @@ import {
Menu, Menu,
MenuItem, MenuItem,
TextField, TextField,
Slider,
Stack,
} from "@mui/material"; } from "@mui/material";
import { import {
authInstance, authInstance,
@@ -45,6 +47,7 @@ export const RightWidgetTab = observer(
updateRightArticleInfo, updateRightArticleInfo,
getRightArticles, getRightArticles,
updateSight, updateSight,
updateSightInfo,
unlinkPreviewMedia, unlinkPreviewMedia,
linkPreviewMedia, linkPreviewMedia,
unlinkRightArticle, unlinkRightArticle,
@@ -454,7 +457,40 @@ export const RightWidgetTab = observer(
</Box> </Box>
</Box> </Box>
<Box sx={{ flexShrink: 0, width: "550px" }}> <Box sx={{ flexShrink: 0, width: "550px", display: "flex", flexDirection: "column", gap: 1 }}>
<Stack direction="row" spacing={2} alignItems="center">
<TextField
type="number"
label="Размер шрифта превью (px)"
size="small"
value={sight.common.preview_font_size ?? ""}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
updateSightInfo(language, { preview_font_size: undefined }, true);
return;
}
const val = Math.max(1, Math.min(300, Math.round(Number(raw))));
if (Number.isFinite(val)) {
updateSightInfo(language, { preview_font_size: val }, true);
}
}}
slotProps={{ input: { min: 1, max: 300 } }}
sx={{ width: "200px" }}
/>
<Slider
value={sight.common.preview_font_size ?? 40}
min={1}
max={300}
step={1}
onChange={(_, newValue) => {
if (typeof newValue === "number") {
updateSightInfo(language, { preview_font_size: newValue }, true);
}
}}
sx={{ flexGrow: 1 }}
/>
</Stack>
<SightFramePreview <SightFramePreview
sightName={sight[language].name} sightName={sight[language].name}
previewMedia={previewMedia} previewMedia={previewMedia}
@@ -463,6 +499,7 @@ export const RightWidgetTab = observer(
handleSelectArticle(idx); handleSelectArticle(idx);
setType("article"); setType("article");
}} }}
previewFontSize={sight.common.preview_font_size}
/> />
</Box> </Box>
</Box> </Box>

View File

@@ -0,0 +1,43 @@
import { observer } from "mobx-react-lite";
import { testingModeStore, authStore } from "@shared";
import { useEffect } from "react";
export const TestingModeBanner = observer(() => {
const { isAuthenticated } = authStore;
useEffect(() => {
if (!isAuthenticated) {
testingModeStore.stopPolling();
return;
}
testingModeStore.startPolling();
return () => {
testingModeStore.stopPolling();
};
}, [isAuthenticated]);
if (!testingModeStore.isEnabled) return null;
return (
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
zIndex: 2147483647,
backgroundColor: "#d32f2f",
color: "#fff",
textAlign: "center",
padding: "12px 16px",
fontWeight: 700,
fontSize: "14px",
letterSpacing: "0.05em",
boxShadow: "0 2px 8px rgba(0,0,0,0.4)",
pointerEvents: "none",
}}
>
ПРОВОДИТСЯ ТЕСТИРОВАНИЕ. ПРОСЬБА НЕ ВЗАИМОДЕЙСТВОВАТЬ С АДМИН-ПАНЕЛЬЮ
</div>
);
});

View File

@@ -20,3 +20,4 @@ export * from "./CreateButton";
export * from "./SaveWithoutCityAgree"; export * from "./SaveWithoutCityAgree";
export * from "./CitySelector"; export * from "./CitySelector";
export * from "./modals"; export * from "./modals";
export * from "./TestingModeBanner";

File diff suppressed because one or more lines are too long