2 Commits

Author SHA1 Message Date
Георгий
b3d902ab74 feat: deploy admin panel to stage
Some checks failed
Build White Nights admin stage / build-and-deploy (push) Failing after 14s
2026-10-27 22:19:45 +03:00
fd582cbab8 feat: support custom maintenance mode duration 2026-10-18 15:07:34 +03:00
11 changed files with 354 additions and 47 deletions

8
.dockerignore Normal file
View 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
View 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"

View 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

View File

@@ -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
View 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
View 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;
}
}

View File

@@ -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");

View File

@@ -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) => {

View File

@@ -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;
};

View File

@@ -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>
))}

View File

@@ -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>