FIX - HUB ADMIN: stabilize NAS persistence

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 13:15:35 +03:00
parent 784195f747
commit 5898b94875
7 changed files with 256 additions and 101 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
node_modules/
dist/
.git/
.env
.env.*
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.tsbuildinfo
server/storage/*
!server/storage/.gitkeep
public/storage/uploads/*
!public/storage/uploads/.gitkeep
public/storage/backups/

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM node:20-alpine AS runner
ENV NODE_ENV=production
ENV PORT=5173
ENV NODEDC_LAUNCHER_STORAGE_DIR=/app/server/storage
WORKDIR /app
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server ./server
COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public
RUN mkdir -p /app/server/storage /app/dist/storage/uploads /app/public/storage/uploads \
&& chown -R node:node /app/server/storage /app/dist/storage /app/public/storage
USER node
EXPOSE 5173
CMD ["node", "server/dev-server.mjs"]

View File

@ -65,6 +65,16 @@ export function createControlPlaneStore({ projectRoot }) {
const serverStorageRoot = resolveStorageRoot(projectRoot); const serverStorageRoot = resolveStorageRoot(projectRoot);
const legacyPublicDataPath = join(projectRoot, "public", "storage", "launcher-data.json"); const legacyPublicDataPath = join(projectRoot, "public", "storage", "launcher-data.json");
const dataPath = join(serverStorageRoot, "launcher-data.json"); const dataPath = join(serverStorageRoot, "launcher-data.json");
let mutationQueue = Promise.resolve();
function enqueueMutation(operation) {
const nextOperation = mutationQueue.catch(() => {}).then(operation);
mutationQueue = nextOperation.then(
() => undefined,
() => undefined
);
return nextOperation;
}
function readData() { function readData() {
const readablePath = existsSync(dataPath) ? dataPath : legacyPublicDataPath; const readablePath = existsSync(dataPath) ? dataPath : legacyPublicDataPath;
@ -351,6 +361,7 @@ export function createControlPlaneStore({ projectRoot }) {
} }
async function updateSettings(payload, identity) { async function updateSettings(payload, identity) {
return enqueueMutation(async () => {
const data = readData(); const data = readData();
const actor = resolveActor(data, identity); const actor = resolveActor(data, identity);
const patch = typeof payload === "object" && payload !== null ? payload : {}; const patch = typeof payload === "object" && payload !== null ? payload : {};
@ -378,6 +389,7 @@ export function createControlPlaneStore({ projectRoot }) {
await writeData(data); await writeData(data);
return { settings, data }; return { settings, data };
});
} }
async function updateUserProfile(userId, payload, identity) { async function updateUserProfile(userId, payload, identity) {
@ -1540,6 +1552,7 @@ export function createControlPlaneStore({ projectRoot }) {
} }
async function updateService(serviceId, payload, identity) { async function updateService(serviceId, payload, identity) {
return enqueueMutation(async () => {
const data = readData(); const data = readData();
const actor = resolveActor(data, identity); const actor = resolveActor(data, identity);
const service = findById(data.services, serviceId, "service"); const service = findById(data.services, serviceId, "service");
@ -1558,6 +1571,7 @@ export function createControlPlaneStore({ projectRoot }) {
await writeData(data); await writeData(data);
return { service, data }; return { service, data };
});
} }
async function reorderServices(payload, identity) { async function reorderServices(payload, identity) {

View File

@ -5,7 +5,6 @@ import { existsSync, readFileSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises"; import { mkdir, writeFile } from "node:fs/promises";
import { dirname, extname, join, resolve } from "node:path"; import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createServer as createViteServer } from "vite";
import { createRemoteJWKSet, jwtVerify } from "jose"; import { createRemoteJWKSet, jwtVerify } from "jose";
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs"; import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.mjs";
import { createControlPlaneStore } from "./control-plane-store.mjs"; import { createControlPlaneStore } from "./control-plane-store.mjs";
@ -1508,19 +1507,49 @@ app.get("/storage/launcher-data.json", (_req, res) => {
res.status(404).json({ error: "not_found" }); res.status(404).json({ error: "not_found" });
}); });
const vite = await createViteServer({ let fixFrontendStacktrace = () => {};
if (process.env.NODE_ENV === "production") {
const distRoot = join(projectRoot, "dist");
const indexHtmlPath = join(distRoot, "index.html");
if (!existsSync(indexHtmlPath)) {
throw new Error("Launcher production build is missing. Run npm run build before starting the server.");
}
app.use(express.static(distRoot, { index: false }));
app.use((req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") {
next();
return;
}
const accept = typeof req.headers.accept === "string" ? req.headers.accept : "";
if (!accept.includes("text/html")) {
next();
return;
}
setNoStore(res);
res.sendFile(indexHtmlPath);
});
} else {
const { createServer: createViteServer } = await import("vite");
const vite = await createViteServer({
root: projectRoot, root: projectRoot,
appType: "spa", appType: "spa",
server: { server: {
middlewareMode: true, middlewareMode: true,
hmr: { server: httpServer }, hmr: { server: httpServer },
}, },
}); });
app.use(vite.middlewares); fixFrontendStacktrace = (error) => vite.ssrFixStacktrace(error);
app.use(vite.middlewares);
}
app.use((error, _req, res, _next) => { app.use((error, _req, res, _next) => {
vite.ssrFixStacktrace(error); fixFrontendStacktrace(error);
const message = error instanceof Error ? error.message : "Unexpected server error"; const message = error instanceof Error ? error.message : "Unexpected server error";
res.status(500).json({ error: message }); res.status(500).json({ error: message });
}); });

View File

@ -86,6 +86,8 @@ type InviteFlowState =
| { status: "registered"; payload: PublicInviteResponse; loginUrl: string } | { status: "registered"; payload: PublicInviteResponse; loginUrl: string }
| { status: "error"; message: string; payload?: PublicInviteResponse }; | { status: "error"; message: string; payload?: PublicInviteResponse };
type ControlPlaneMutationOutcome = { ok: true; data: LauncherData } | { ok: false; message: string };
export function LauncherApp() { export function LauncherApp() {
const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []); const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []);
const isAccessRequestRoute = useMemo(() => isAccessRequestPath(window.location.pathname), []); const isAccessRequestRoute = useMemo(() => isAccessRequestPath(window.location.pathname), []);
@ -490,14 +492,16 @@ export function LauncherApp() {
}); });
} }
function applyControlPlaneMutation(request: Promise<ControlPlaneMutationResult>) { async function applyControlPlaneMutation(request: Promise<ControlPlaneMutationResult>): Promise<ControlPlaneMutationOutcome> {
request try {
.then((result) => { const result = await request;
setData(syncLauncherServiceLinks(result.data)); setData(syncLauncherServiceLinks(result.data));
}) return { ok: true, data: result.data };
.catch((error: unknown) => { } catch (error: unknown) {
console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию"); const message = error instanceof Error ? error.message : "Не удалось выполнить admin API операцию";
}); console.warn(message);
return { ok: false, message };
}
} }
async function refreshTaskManagerWorkspaces() { async function refreshTaskManagerWorkspaces() {
@ -697,11 +701,11 @@ export function LauncherApp() {
} }
function handleUpdateSettings(patch: Partial<LauncherSettings>) { function handleUpdateSettings(patch: Partial<LauncherSettings>) {
applyControlPlaneMutation(updateAdminSettings(patch)); return applyControlPlaneMutation(updateAdminSettings(patch));
} }
function handleUpdateService(serviceId: string, patch: Partial<Service>) { function handleUpdateService(serviceId: string, patch: Partial<Service>) {
applyControlPlaneMutation(updateAdminService(serviceId, patch)); return applyControlPlaneMutation(updateAdminService(serviceId, patch));
} }
function handleCreateClient() { function handleCreateClient() {

View File

@ -4237,6 +4237,17 @@ code {
max-width: 44rem; max-width: 44rem;
} }
.admin-settings-save-message {
margin: -0.35rem 0 1rem;
color: rgba(135, 255, 190, 0.92);
font-size: 0.76rem;
font-weight: 750;
}
.admin-settings-save-message--error {
color: #ffd0d0;
}
.admin-settings-grid { .admin-settings-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;

View File

@ -90,6 +90,7 @@ type AdminSection =
| "misc" | "misc"
| "company"; | "company";
type AdminOverlayMode = "admin" | "platform"; type AdminOverlayMode = "admin" | "platform";
type AdminMutationOutcome = { ok: true } | { ok: false; message: string };
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">; type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset"; export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
@ -229,11 +230,11 @@ export function AdminOverlay({
onCreateGroup: (clientId: string) => void; onCreateGroup: (clientId: string) => void;
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void; onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
onDeleteGroup: (groupId: string) => void; onDeleteGroup: (groupId: string) => void;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void; onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onReorderServices: (orderedServiceIds: string[]) => void; onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void; onCreateService: () => void;
onDeleteService: (serviceId: string) => void; onDeleteService: (serviceId: string) => void;
onUpdateSettings: (patch: Partial<LauncherSettings>) => void; onUpdateSettings: (patch: Partial<LauncherSettings>) => Promise<AdminMutationOutcome>;
taskManagerWorkspaces: TaskManagerWorkspaceSummary[]; taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
taskManagerWorkspacesLoading: boolean; taskManagerWorkspacesLoading: boolean;
taskManagerWorkspacesError: string | null; taskManagerWorkspacesError: string | null;
@ -1546,7 +1547,7 @@ function ServicesSection({
}: { }: {
data: LauncherData; data: LauncherData;
isPublicPoolContext: boolean; isPublicPoolContext: boolean;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void; onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onReorderServices: (orderedServiceIds: string[]) => void; onReorderServices: (orderedServiceIds: string[]) => void;
onCreateService: () => void; onCreateService: () => void;
onDeleteService: (serviceId: string) => void; onDeleteService: (serviceId: string) => void;
@ -1653,9 +1654,12 @@ function ServicesSection({
<ServiceContentModal <ServiceContentModal
service={contentService} service={contentService}
onClose={() => setContentServiceId(null)} onClose={() => setContentServiceId(null)}
onSave={(patch) => { onSave={async (patch) => {
onUpdateService(contentService.id, patch); const result = await onUpdateService(contentService.id, patch);
if (result.ok) {
setContentServiceId(null); setContentServiceId(null);
}
return result;
}} }}
onDelete={() => { onDelete={() => {
onDeleteService(contentService.id); onDeleteService(contentService.id);
@ -1687,7 +1691,7 @@ function SortableServiceRow({
onOpenContent, onOpenContent,
}: { }: {
service: Service; service: Service;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void; onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onOpenContent: () => void; onOpenContent: () => void;
}) { }) {
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: service.id }); const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: service.id });
@ -1719,7 +1723,7 @@ function ServiceTableCells({
setDragHandleRef, setDragHandleRef,
}: { }: {
service: Service; service: Service;
onUpdateService: (serviceId: string, patch: Partial<Service>) => void; onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onOpenContent: () => void; onOpenContent: () => void;
dragAttributes?: DraggableAttributes; dragAttributes?: DraggableAttributes;
dragListeners?: DraggableSyntheticListeners; dragListeners?: DraggableSyntheticListeners;
@ -1927,7 +1931,7 @@ function ServiceContentModal({
}: { }: {
service: Service; service: Service;
onClose: () => void; onClose: () => void;
onSave: (patch: Partial<Service>) => void; onSave: (patch: Partial<Service>) => Promise<AdminMutationOutcome>;
onDelete: () => void; onDelete: () => void;
}) { }) {
const [draft, setDraft] = useState<Service>(service); const [draft, setDraft] = useState<Service>(service);
@ -1937,6 +1941,8 @@ function ServiceContentModal({
}); });
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null); const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null);
const [storageError, setStorageError] = useState<string | null>(null); const [storageError, setStorageError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
useEffect(() => { useEffect(() => {
@ -1946,6 +1952,8 @@ function ServiceContentModal({
ambient: service.ambientVideoUrl ?? null, ambient: service.ambientVideoUrl ?? null,
}); });
setStorageError(null); setStorageError(null);
setSaveError(null);
setIsSaving(false);
setUploadingSlot(null); setUploadingSlot(null);
}, [service]); }, [service]);
@ -2006,6 +2014,33 @@ function ServiceContentModal({
} }
} }
async function handleSave() {
setSaveError(null);
setIsSaving(true);
const result = await onSave({
title: draft.title,
subtitle: draft.subtitle,
description: draft.description,
fullDescription: draft.fullDescription,
url: draft.url,
launchUrl: draft.launchUrl,
coverImageUrl: draft.coverImageUrl,
coverMediaKind: draft.coverMediaKind,
coverMediaSource: draft.coverMediaSource,
coverMediaFileName: draft.coverMediaFileName,
ambientVideoUrl: draft.ambientVideoUrl,
ambientMediaKind: draft.ambientMediaKind,
ambientMediaSource: draft.ambientMediaSource,
ambientMediaFileName: draft.ambientMediaFileName,
});
if (!result.ok) {
setSaveError(result.message);
setIsSaving(false);
}
}
return ( return (
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Контент витрины ${service.title}`}> <div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Контент витрины ${service.title}`}>
<article className="service-content-modal"> <article className="service-content-modal">
@ -2091,6 +2126,7 @@ function ServiceContentModal({
/> />
{storageError ? <p className="service-content-storage-error">{storageError}</p> : null} {storageError ? <p className="service-content-storage-error">{storageError}</p> : null}
{saveError ? <p className="service-content-storage-error">{saveError}</p> : null}
</div> </div>
<div className="service-content-modal__foot"> <div className="service-content-modal__foot">
@ -2106,28 +2142,11 @@ function ServiceContentModal({
surface="modal" surface="modal"
accentRgb={modalActionAccentRgb} accentRgb={modalActionAccentRgb}
type="button" type="button"
disabled={uploadingSlot !== null} disabled={uploadingSlot !== null || isSaving}
icon={<Save size={16} />} icon={<Save size={16} />}
onClick={() => onClick={handleSave}
onSave({
title: draft.title,
subtitle: draft.subtitle,
description: draft.description,
fullDescription: draft.fullDescription,
url: draft.url,
launchUrl: draft.launchUrl,
coverImageUrl: draft.coverImageUrl,
coverMediaKind: draft.coverMediaKind,
coverMediaSource: draft.coverMediaSource,
coverMediaFileName: draft.coverMediaFileName,
ambientVideoUrl: draft.ambientVideoUrl,
ambientMediaKind: draft.ambientMediaKind,
ambientMediaSource: draft.ambientMediaSource,
ambientMediaFileName: draft.ambientMediaFileName,
})
}
> >
{uploadingSlot ? "Сохраняем файл" : "Сохранить"} {uploadingSlot ? "Сохраняем файл" : isSaving ? "Сохраняем" : "Сохранить"}
</Button> </Button>
</div> </div>
</div> </div>
@ -4408,12 +4427,14 @@ function MiscSection({
onUpdateSettings, onUpdateSettings,
}: { }: {
data: LauncherData; data: LauncherData;
onUpdateSettings: (patch: Partial<LauncherSettings>) => void; onUpdateSettings: (patch: Partial<LauncherSettings>) => Promise<AdminMutationOutcome>;
}) { }) {
const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl); const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl);
const [workspaceCreationPolicy, setWorkspaceCreationPolicy] = useState( const [workspaceCreationPolicy, setWorkspaceCreationPolicy] = useState(
data.settings.taskManager.workspaceCreationPolicy data.settings.taskManager.workspaceCreationPolicy
); );
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [saveMessage, setSaveMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setLogoLinkUrl(data.settings.brand.logoLinkUrl); setLogoLinkUrl(data.settings.brand.logoLinkUrl);
@ -4425,6 +4446,25 @@ function MiscSection({
normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl || normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl ||
workspaceCreationPolicy !== data.settings.taskManager.workspaceCreationPolicy; workspaceCreationPolicy !== data.settings.taskManager.workspaceCreationPolicy;
async function handleSave() {
setSaveState("saving");
setSaveMessage(null);
const result = await onUpdateSettings({
brand: { logoLinkUrl: normalizedLogoLinkUrl },
taskManager: { workspaceCreationPolicy },
});
if (result.ok) {
setSaveState("saved");
setSaveMessage("Сохранено");
return;
}
setSaveState("error");
setSaveMessage(result.message);
}
return ( return (
<GlassSurface className="table-shell admin-settings-panel"> <GlassSurface className="table-shell admin-settings-panel">
<div className="table-toolbar"> <div className="table-toolbar">
@ -4438,17 +4478,17 @@ function MiscSection({
variant="accent" variant="accent"
type="button" type="button"
icon={<Save size={16} />} icon={<Save size={16} />}
disabled={!hasChanges} disabled={!hasChanges || saveState === "saving"}
onClick={() => onClick={handleSave}
onUpdateSettings({
brand: { logoLinkUrl: normalizedLogoLinkUrl },
taskManager: { workspaceCreationPolicy },
})
}
> >
Сохранить {saveState === "saving" ? "Сохраняем" : "Сохранить"}
</Button> </Button>
</div> </div>
{saveMessage ? (
<p className={cn("admin-settings-save-message", saveState === "error" && "admin-settings-save-message--error")}>
{saveMessage}
</p>
) : null}
<div className="admin-settings-grid"> <div className="admin-settings-grid">
<label className="admin-settings-field"> <label className="admin-settings-field">
@ -4457,7 +4497,11 @@ function MiscSection({
className="admin-table-input admin-settings-field__input" className="admin-table-input admin-settings-field__input"
value={logoLinkUrl} value={logoLinkUrl}
placeholder="/" placeholder="/"
onChange={(event) => setLogoLinkUrl(event.target.value)} onChange={(event) => {
setLogoLinkUrl(event.target.value);
setSaveState("idle");
setSaveMessage(null);
}}
/> />
<small>Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL.</small> <small>Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL.</small>
</label> </label>
@ -4469,7 +4513,11 @@ function MiscSection({
value={workspaceCreationPolicy} value={workspaceCreationPolicy}
options={taskManagerWorkspacePolicyOptions} options={taskManagerWorkspacePolicyOptions}
label="Политика создания workspace в Operational Core" label="Политика создания workspace в Operational Core"
onChange={(value) => setWorkspaceCreationPolicy(value)} onChange={(value) => {
setWorkspaceCreationPolicy(value);
setSaveState("idle");
setSaveMessage(null);
}}
/> />
<small>Платформенная policy для новых пользователей без назначенных рабочих пространств в Task Manager.</small> <small>Платформенная policy для новых пользователей без назначенных рабочих пространств в Task Manager.</small>
</label> </label>