Compare commits
2 Commits
main
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3d902ab74 | ||
| fd582cbab8 |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Backend content/auth API and media endpoint.
|
||||
VITE_API_URL="https://server.white-nights-stage.makkah.verstack.ru"
|
||||
VITE_KRBL_MEDIA="https://server.white-nights-stage.makkah.verstack.ru/media/"
|
||||
|
||||
# Device APIs used by the embedded route demo at /demo/:id.
|
||||
VITE_GEO_API_URL="https://device.white-nights-stage.makkah.verstack.ru"
|
||||
VITE_WEATHER_API_URL="https://device.white-nights-stage.makkah.verstack.ru"
|
||||
|
||||
VITE_NEED_AUTH="true"
|
||||
57
.gitea/workflows/build-stage.yaml
Normal file
57
.gitea/workflows/build-stage.yaml
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Build White Nights admin stage
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- feature/stage-service-endpoints
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set image metadata
|
||||
id: meta
|
||||
run: |
|
||||
IMAGE_PREFIX="${{ secrets.REGISTRY_URL }}/krbl/whitenightsadminpanel"
|
||||
echo "IMAGE_PREFIX=${IMAGE_PREFIX}" >> "$GITEA_OUTPUT"
|
||||
echo "IMAGE_TAG=$(echo \"${{ gitea.sha }}\" | cut -c1-7)" >> "$GITEA_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ secrets.REGISTRY_URL }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push admin panel
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
build-args: |
|
||||
VITE_API_URL=https://server.white-nights-stage.makkah.verstack.ru
|
||||
VITE_KRBL_MEDIA=https://server.white-nights-stage.makkah.verstack.ru/media/
|
||||
VITE_GEO_API_URL=https://device.white-nights-stage.makkah.verstack.ru
|
||||
VITE_WEATHER_API_URL=https://device.white-nights-stage.makkah.verstack.ru
|
||||
tags: |
|
||||
${{ steps.meta.outputs.IMAGE_PREFIX }}/service-admin-panel:${{ steps.meta.outputs.IMAGE_TAG }}
|
||||
${{ steps.meta.outputs.IMAGE_PREFIX }}/service-admin-panel:stage
|
||||
|
||||
- name: Restart backend pod to pick up the stage frontend
|
||||
env:
|
||||
KUBE_CONFIG_DATA: ${{ secrets.WNS_KUBE_CONFIG }}
|
||||
run: |
|
||||
set -eu
|
||||
KUBECONFIG_PATH="$RUNNER_TEMP/wn-kubeconfig"
|
||||
printf '%s' "$KUBE_CONFIG_DATA" > "$KUBECONFIG_PATH"
|
||||
chmod 600 "$KUBECONFIG_PATH"
|
||||
export KUBECONFIG="$KUBECONFIG_PATH"
|
||||
kubectl -n white-nights-stage rollout restart deployment/wn-backend
|
||||
kubectl -n white-nights-stage rollout status deployment/wn-backend --timeout=5m
|
||||
27
Dockerfile
27
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build the application
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:24-alpine AS build
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
@@ -7,6 +7,13 @@ WORKDIR /app
|
||||
# Copy package.json and yarn.lock
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Vite embeds these values into the static bundle at build time. When an
|
||||
# argument is omitted, the checked-in .env remains the fallback.
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_GEO_API_URL
|
||||
ARG VITE_WEATHER_API_URL
|
||||
ARG VITE_KRBL_MEDIA
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
@@ -14,19 +21,23 @@ RUN yarn install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN yarn build
|
||||
RUN if [ -n "${VITE_API_URL:-}" ]; then export VITE_API_URL; fi && \
|
||||
if [ -n "${VITE_GEO_API_URL:-}" ]; then export VITE_GEO_API_URL; fi && \
|
||||
if [ -n "${VITE_WEATHER_API_URL:-}" ]; then export VITE_WEATHER_API_URL; fi && \
|
||||
if [ -n "${VITE_KRBL_MEDIA:-}" ]; then export VITE_KRBL_MEDIA; fi && \
|
||||
yarn build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:alpine
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
# Copy the built application from the build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration (optional, can be added later if needed)
|
||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# Keep client-side routes working on direct navigation and page refresh.
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
# The unprivileged image cannot bind to port 80.
|
||||
EXPOSE 8080
|
||||
|
||||
# Start Nginx server
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
22
STAGE_DEPLOYMENT.md
Normal file
22
STAGE_DEPLOYMENT.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Stage deployment
|
||||
|
||||
The admin panel is a static Vite bundle. The backend and device remain
|
||||
separate services.
|
||||
|
||||
Build a stage image with the service URLs embedded into the bundle:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg VITE_API_URL=https://server.white-nights-stage.makkah.verstack.ru \
|
||||
--build-arg VITE_KRBL_MEDIA=https://server.white-nights-stage.makkah.verstack.ru/media/ \
|
||||
--build-arg VITE_GEO_API_URL=https://device.white-nights-stage.makkah.verstack.ru \
|
||||
--build-arg VITE_WEATHER_API_URL=https://device.white-nights-stage.makkah.verstack.ru \
|
||||
-t white-nights-admin-panel:stage .
|
||||
```
|
||||
|
||||
The backend URL serves content, authentication, media and admin API requests.
|
||||
The device URL serves geolocation and weather requests used by `/demo/:id`.
|
||||
|
||||
The device ingress must publish the geolocation and weather paths to the
|
||||
corresponding device ports. The browser must also be authorized by Authelia
|
||||
and the backend must allow the frontend origin through CORS.
|
||||
11
nginx.conf
Normal file
11
nginx.conf
Normal file
@@ -0,0 +1,11 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import axios from "axios";
|
||||
|
||||
const envUrl = import.meta.env.VITE_API_URL || "localhost";
|
||||
const baseURL = envUrl.includes("://") ? envUrl : `http://${envUrl}`;
|
||||
const isFullUrl =
|
||||
baseURL.includes("://") &&
|
||||
!envUrl.includes("localhost") &&
|
||||
!envUrl.includes("127.0.0.1");
|
||||
const trimTrailingSlash = (value) => value.replace(/\/+$/, "");
|
||||
|
||||
const serviceURL = (rawValue, defaultPort) => {
|
||||
const value = String(rawValue || "").trim() || "localhost";
|
||||
const hasScheme = value.includes("://");
|
||||
const url = new URL(hasScheme ? value : `http://${value}`);
|
||||
|
||||
// Bare local hosts use service-specific ports. Full URLs keep their
|
||||
// configured scheme and port, which is required behind ingress.
|
||||
if (!hasScheme && !url.port) {
|
||||
url.port = String(defaultPort);
|
||||
}
|
||||
|
||||
return trimTrailingSlash(url.toString());
|
||||
};
|
||||
|
||||
const apiURL = import.meta.env.VITE_API_URL || "localhost";
|
||||
const geoURL = import.meta.env.VITE_GEO_API_URL || apiURL;
|
||||
const weatherURL = import.meta.env.VITE_WEATHER_API_URL || apiURL;
|
||||
|
||||
const addAuthInterceptor = (instance) => {
|
||||
instance.interceptors.request.use((config) => {
|
||||
@@ -17,9 +30,9 @@ const addAuthInterceptor = (instance) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const apiBaseURL = isFullUrl ? baseURL : `${baseURL}:8080`;
|
||||
export const geoBaseURL = isFullUrl ? baseURL : `${baseURL}:6001`;
|
||||
export const weatherBaseURL = isFullUrl ? baseURL : `${baseURL}:6002`;
|
||||
export const apiBaseURL = serviceURL(apiURL, 8080);
|
||||
export const geoBaseURL = serviceURL(geoURL, 6001);
|
||||
export const weatherBaseURL = serviceURL(weatherURL, 6002);
|
||||
|
||||
export const getMediaUrl = (id) => {
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authInstance, languageInstance, mobxFetch } from "@shared";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
import { getVehicleSessionsApi } from "./api";
|
||||
import { Vehicle, VehicleMaintenanceSession } from "./types";
|
||||
import { Vehicle, VehicleMaintenanceSession, SetMaintenanceModeResponse } from "./types";
|
||||
|
||||
export type { Vehicle, VehicleMaintenanceSession } from "./types";
|
||||
|
||||
@@ -218,23 +218,32 @@ class VehicleStore {
|
||||
});
|
||||
};
|
||||
|
||||
setMaintenanceMode = async (uuid: string, enabled: boolean) => {
|
||||
const response = await authInstance.post(
|
||||
setMaintenanceMode = async (
|
||||
uuid: string,
|
||||
enabled: boolean,
|
||||
durationSeconds?: number,
|
||||
): Promise<SetMaintenanceModeResponse> => {
|
||||
const response = await authInstance.post<SetMaintenanceModeResponse>(
|
||||
`/devices/${uuid}/maintenance-mode`,
|
||||
{
|
||||
enabled,
|
||||
...(enabled && durationSeconds !== undefined
|
||||
? { duration_seconds: durationSeconds }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
const normalizedVehicle = this.normalizeVehicleItem(response.data);
|
||||
if (!response.data.saved) {
|
||||
throw new Error("Maintenance mode was not saved");
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.mergeVehicleInCaches({
|
||||
...normalizedVehicle.vehicle,
|
||||
uuid,
|
||||
maintenance_mode_on:
|
||||
normalizedVehicle.vehicle.maintenance_mode_on ?? enabled,
|
||||
maintenance_mode_on: response.data.enabled,
|
||||
maintenance_mode_duration_seconds: response.data.duration_seconds,
|
||||
});
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
setDemoMode = async (uuid: string, enabled: boolean) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Vehicle = {
|
||||
snapshot_update_blocked?: boolean;
|
||||
demo_mode_enabled?: boolean;
|
||||
maintenance_mode_on?: boolean;
|
||||
maintenance_mode_duration_seconds?: number;
|
||||
city_id?: number;
|
||||
};
|
||||
device_status?: {
|
||||
@@ -21,13 +22,24 @@ export type Vehicle = {
|
||||
last_update: string;
|
||||
is_connected: boolean;
|
||||
current_route_id?: number;
|
||||
maintenance_mode_on?: boolean;
|
||||
maintenance_mode_duration_seconds?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type VehicleMaintenanceSession = {
|
||||
duration_seconds: number;
|
||||
ended_at: string;
|
||||
duration_seconds?: number | null;
|
||||
ended_at?: string | null;
|
||||
id: number;
|
||||
started_at: string;
|
||||
vehicle_id: number;
|
||||
};
|
||||
|
||||
export type SetMaintenanceModeResponse = {
|
||||
message: string;
|
||||
enabled: boolean;
|
||||
duration_seconds: number;
|
||||
saved: boolean;
|
||||
sent: boolean;
|
||||
warning?: string;
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ interface VehicleSessionsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const formatDateTime = (value: string) => {
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return "-";
|
||||
|
||||
const date = new Date(value);
|
||||
@@ -38,8 +38,8 @@ const formatDateTime = (value: string) => {
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const formatDuration = (durationSeconds: number) => {
|
||||
if (!Number.isFinite(durationSeconds) || durationSeconds < 0) {
|
||||
const formatDuration = (durationSeconds?: number | null) => {
|
||||
if (durationSeconds == null || !Number.isFinite(durationSeconds) || durationSeconds < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
@@ -152,10 +152,10 @@ export const VehicleSessionsModal = observer(
|
||||
{formatDateTime(session.started_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDateTime(session.ended_at)}
|
||||
{session.ended_at ? formatDateTime(session.ended_at) : "В процессе"}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatDuration(session.duration_seconds)}
|
||||
{session.ended_at ? formatDuration(session.duration_seconds) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -34,12 +34,26 @@ import {
|
||||
Typography,
|
||||
Checkbox,
|
||||
Tooltip,
|
||||
TextField,
|
||||
MenuItem,
|
||||
} from "@mui/material";
|
||||
import { toast } from "react-toastify";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DeleteModal } from "@widgets";
|
||||
import { DeviceLogsModal } from "./DeviceLogsModal";
|
||||
import { VehicleSessionsModal } from "./VehicleSessionsModal";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
const maintenanceDurations = [0, 900, 1800, 3600, 7200];
|
||||
const MAX_MAINTENANCE_DURATION_SECONDS = 2_147_483_647;
|
||||
const formatMaintenanceDuration = (seconds: number) => {
|
||||
if (seconds === 0) return "Без автоматического отключения";
|
||||
return [
|
||||
Math.floor(seconds / 3600) ? `${Math.floor(seconds / 3600)} ч` : "",
|
||||
Math.floor((seconds % 3600) / 60) ? `${Math.floor((seconds % 3600) / 60)} мин` : "",
|
||||
seconds % 60 ? `${seconds % 60} с` : "",
|
||||
].filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
export type ConnectedDevice = string;
|
||||
|
||||
@@ -81,6 +95,7 @@ type RowData = {
|
||||
current_snapshot_uuid: string | null;
|
||||
snapshot_update_blocked: boolean;
|
||||
maintenance_mode_on: boolean;
|
||||
maintenance_mode_duration_seconds: number;
|
||||
demo_mode_enabled: boolean;
|
||||
current_route_id: number | null;
|
||||
};
|
||||
@@ -120,6 +135,7 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => {
|
||||
current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null,
|
||||
snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false,
|
||||
maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false,
|
||||
maintenance_mode_duration_seconds: vehicle.vehicle.maintenance_mode_duration_seconds ?? 0,
|
||||
demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false,
|
||||
current_route_id: vehicle.device_status?.current_route_id ?? null,
|
||||
};
|
||||
@@ -169,6 +185,11 @@ export const DevicesTable = observer(() => {
|
||||
);
|
||||
const [maintenanceConfirm, setMaintenanceConfirm] =
|
||||
useState<PendingModeToggle | null>(null);
|
||||
const [maintenanceDuration, setMaintenanceDuration] = useState(0);
|
||||
const [customMaintenanceDuration, setCustomMaintenanceDuration] = useState(false);
|
||||
const [customMaintenanceHours, setCustomMaintenanceHours] = useState(0);
|
||||
const [customMaintenanceMinutes, setCustomMaintenanceMinutes] = useState(0);
|
||||
const [savedMaintenanceDuration, setSavedMaintenanceDuration] = useState(0);
|
||||
const [demoConfirm, setDemoConfirm] = useState<PendingModeToggle | null>(
|
||||
null,
|
||||
);
|
||||
@@ -238,6 +259,7 @@ export const DevicesTable = observer(() => {
|
||||
}, [rows, selectedIds]);
|
||||
|
||||
const applyMaintenanceMode = async (toggle: PendingModeToggle) => {
|
||||
if (!canMaintenanceDevices) return false;
|
||||
setMaintenanceLoadingUuids((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(toggle.deviceUuid);
|
||||
@@ -245,20 +267,42 @@ export const DevicesTable = observer(() => {
|
||||
});
|
||||
|
||||
try {
|
||||
await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled);
|
||||
await getVehicles();
|
||||
await getDevices();
|
||||
toast.success(
|
||||
toggle.nextEnabled
|
||||
? "Устройство отправлено на ТО"
|
||||
: "Режим ТО отключен",
|
||||
const result = await setMaintenanceMode(
|
||||
toggle.deviceUuid,
|
||||
toggle.nextEnabled,
|
||||
toggle.nextEnabled ? maintenanceDuration : undefined,
|
||||
);
|
||||
if (!result.sent) {
|
||||
toast.warning(
|
||||
result.enabled
|
||||
? "Настройка сохранена. Режим ТО включится после подключения устройства."
|
||||
: "Настройка сохранена. Режим ТО отключится после подключения устройства.",
|
||||
);
|
||||
} else {
|
||||
toast.success(result.enabled
|
||||
? `Команда включения ТО отправлена. Длительность: ${formatMaintenanceDuration(result.duration_seconds)}`
|
||||
: "Команда отключения ТО отправлена");
|
||||
}
|
||||
const refresh = await Promise.allSettled([getVehicles(), getDevices()]);
|
||||
if (refresh.some((result) => result.status === "rejected")) {
|
||||
toast.warning("Настройка ТО сохранена, но обновить таблицу не удалось. Обновите страницу.");
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error toggling maintenance mode for ${toggle.deviceUuid}:`,
|
||||
error,
|
||||
);
|
||||
toast.error("Не удалось изменить режим ТО");
|
||||
const status = isAxiosError(error) ? error.response?.status : undefined;
|
||||
const messages: Record<number, string> = {
|
||||
400: "Некорректные параметры режима ТО",
|
||||
401: "Войдите в систему, чтобы изменить режим ТО",
|
||||
403: "Нет права на управление режимом ТО",
|
||||
404: "Устройство не найдено",
|
||||
500: "Сервер не смог сохранить настройку ТО",
|
||||
};
|
||||
toast.error((status && messages[status]) || "Не удалось изменить режим ТО");
|
||||
return false;
|
||||
} finally {
|
||||
setMaintenanceLoadingUuids((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -298,7 +342,12 @@ export const DevicesTable = observer(() => {
|
||||
};
|
||||
|
||||
const openMaintenanceConfirm = (row: RowData) => {
|
||||
if (!row.device_uuid) return;
|
||||
if (!row.device_uuid || !canMaintenanceDevices || maintenanceConfirmSubmitting) return;
|
||||
setMaintenanceDuration(row.maintenance_mode_duration_seconds);
|
||||
setCustomMaintenanceDuration(false);
|
||||
setCustomMaintenanceHours(Math.floor(row.maintenance_mode_duration_seconds / 3600));
|
||||
setCustomMaintenanceMinutes(Math.floor((row.maintenance_mode_duration_seconds % 3600) / 60));
|
||||
setSavedMaintenanceDuration(row.maintenance_mode_duration_seconds);
|
||||
setMaintenanceConfirm({
|
||||
deviceUuid: row.device_uuid,
|
||||
nextEnabled: !row.maintenance_mode_on,
|
||||
@@ -316,12 +365,13 @@ export const DevicesTable = observer(() => {
|
||||
};
|
||||
|
||||
const handleConfirmMaintenanceToggle = async () => {
|
||||
if (!maintenanceConfirm) return;
|
||||
if (!maintenanceConfirm || !canMaintenanceDevices || maintenanceConfirmSubmitting) return;
|
||||
|
||||
setMaintenanceConfirmSubmitting(true);
|
||||
try {
|
||||
await applyMaintenanceMode(maintenanceConfirm);
|
||||
setMaintenanceConfirm(null);
|
||||
if (await applyMaintenanceMode(maintenanceConfirm)) {
|
||||
setMaintenanceConfirm(null);
|
||||
}
|
||||
} finally {
|
||||
setMaintenanceConfirmSubmitting(false);
|
||||
}
|
||||
@@ -441,9 +491,13 @@ export const DevicesTable = observer(() => {
|
||||
sx={{ display: "flex", justifyContent: "center", width: "100%" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip title={`Сохранённая длительность ТО: ${formatMaintenanceDuration(rowData.maintenance_mode_duration_seconds)}`}>
|
||||
<span>
|
||||
<Checkbox
|
||||
checked={rowData.maintenance_mode_on}
|
||||
inputProps={{ "aria-label": `Режим ТО: ${rowData.tail_number}` }}
|
||||
disabled={
|
||||
!canMaintenanceDevices ||
|
||||
!rowData.device_uuid ||
|
||||
isMaintenanceLoading ||
|
||||
maintenanceConfirmSubmitting
|
||||
@@ -452,6 +506,8 @@ export const DevicesTable = observer(() => {
|
||||
sx={{ p: 0 }}
|
||||
onChange={() => openMaintenanceConfirm(rowData)}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
@@ -694,6 +750,7 @@ export const DevicesTable = observer(() => {
|
||||
demoConfirmSubmitting,
|
||||
routes,
|
||||
canWriteDevices,
|
||||
canMaintenanceDevices,
|
||||
isMaintenanceOnly,
|
||||
],
|
||||
);
|
||||
@@ -708,7 +765,7 @@ export const DevicesTable = observer(() => {
|
||||
}
|
||||
if (!canWriteDevices) {
|
||||
return columns.filter(
|
||||
(c) => c.field !== "maintenance_mode_on" && c.field !== "demo_mode_enabled",
|
||||
(c) => c.field !== "demo_mode_enabled",
|
||||
);
|
||||
}
|
||||
return columns;
|
||||
@@ -732,6 +789,29 @@ export const DevicesTable = observer(() => {
|
||||
fetchData();
|
||||
}, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
let refreshing = false;
|
||||
const refreshMaintenance = async () => {
|
||||
if (document.visibilityState !== "visible" || refreshing || maintenanceConfirmSubmitting) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
const results = await Promise.allSettled([getVehicles(), getDevices()]);
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
console.error("Failed to refresh maintenance state:", result.reason);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
};
|
||||
const timer = window.setInterval(refreshMaintenance, 15_000);
|
||||
document.addEventListener("visibilitychange", refreshMaintenance);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", refreshMaintenance);
|
||||
};
|
||||
}, [getVehicles, getDevices, maintenanceConfirmSubmitting]);
|
||||
|
||||
const handleOpenSendSnapshotModal = () => {
|
||||
if (!canWriteDevices) {
|
||||
@@ -1027,13 +1107,83 @@ export const DevicesTable = observer(() => {
|
||||
sx={{ width: "min(640px, 92vw)", p: 3 }}
|
||||
>
|
||||
<Box component="h2" sx={{ mb: 1, typography: "h6" }}>
|
||||
Подтверждение режима ТО
|
||||
{maintenanceConfirm?.nextEnabled ? "Включить режим ТО" : "Отключить режим ТО"}
|
||||
</Box>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{maintenanceConfirm?.nextEnabled
|
||||
? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?`
|
||||
: `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`}
|
||||
</Box>
|
||||
{maintenanceConfirm?.nextEnabled && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
label="Длительность"
|
||||
value={customMaintenanceDuration ? -1 : maintenanceDuration}
|
||||
disabled={maintenanceConfirmSubmitting}
|
||||
onChange={(event) => {
|
||||
const seconds = Number(event.target.value);
|
||||
if (seconds === -1) {
|
||||
setCustomMaintenanceDuration(true);
|
||||
return;
|
||||
}
|
||||
setCustomMaintenanceDuration(false);
|
||||
setMaintenanceDuration(seconds);
|
||||
}}
|
||||
helperText="Автоотключение выполняется на устройстве после применения команды."
|
||||
>
|
||||
{maintenanceDurations.map((seconds) => (
|
||||
<MenuItem key={seconds} value={seconds}>
|
||||
{formatMaintenanceDuration(seconds)}
|
||||
</MenuItem>
|
||||
))}
|
||||
{!maintenanceDurations.includes(savedMaintenanceDuration) && (
|
||||
<MenuItem value={savedMaintenanceDuration}>
|
||||
{formatMaintenanceDuration(savedMaintenanceDuration)} (сохранено)
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem value={-1}>Своё время</MenuItem>
|
||||
</TextField>
|
||||
{customMaintenanceDuration && (
|
||||
<Box sx={{ display: "flex", gap: 1.5, mt: 2 }}>
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
label="Часы"
|
||||
value={customMaintenanceHours}
|
||||
disabled={maintenanceConfirmSubmitting}
|
||||
inputProps={{ min: 0, max: Math.floor(MAX_MAINTENANCE_DURATION_SECONDS / 3600), step: 1 }}
|
||||
onChange={(event) => {
|
||||
const hours = Math.max(0, Math.floor(Number(event.target.value) || 0));
|
||||
setCustomMaintenanceHours(hours);
|
||||
setMaintenanceDuration(hours * 3600 + customMaintenanceMinutes * 60);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
fullWidth
|
||||
label="Минуты"
|
||||
value={customMaintenanceMinutes}
|
||||
disabled={maintenanceConfirmSubmitting}
|
||||
inputProps={{ min: 0, max: 59, step: 1 }}
|
||||
onChange={(event) => {
|
||||
const minutes = Math.min(59, Math.max(0, Math.floor(Number(event.target.value) || 0)));
|
||||
setCustomMaintenanceMinutes(minutes);
|
||||
setMaintenanceDuration(customMaintenanceHours * 3600 + minutes * 60);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{customMaintenanceDuration && (maintenanceDuration < 60 || maintenanceDuration > MAX_MAINTENANCE_DURATION_SECONDS) && (
|
||||
<Typography color="error" variant="caption" sx={{ display: "block", mt: 1 }}>
|
||||
{maintenanceDuration < 60
|
||||
? "Укажите длительность не меньше одной минуты."
|
||||
: "Указанная длительность превышает допустимый максимум."}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: "flex", gap: 1.5 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -1047,10 +1197,15 @@ export const DevicesTable = observer(() => {
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
disabled={maintenanceConfirmSubmitting}
|
||||
disabled={
|
||||
maintenanceConfirmSubmitting ||
|
||||
(Boolean(maintenanceConfirm?.nextEnabled) &&
|
||||
customMaintenanceDuration &&
|
||||
(maintenanceDuration < 60 || maintenanceDuration > MAX_MAINTENANCE_DURATION_SECONDS))
|
||||
}
|
||||
onClick={handleConfirmMaintenanceToggle}
|
||||
>
|
||||
Подтвердить
|
||||
{maintenanceConfirmSubmitting ? "Сохранение…" : maintenanceConfirm?.nextEnabled ? "Включить" : "Отключить"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user