FIX - HUB ADMIN: stabilize NAS persistence
This commit is contained in:
parent
784195f747
commit
5898b94875
|
|
@ -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/
|
||||
|
|
@ -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"]
|
||||
|
|
@ -65,6 +65,16 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
const serverStorageRoot = resolveStorageRoot(projectRoot);
|
||||
const legacyPublicDataPath = join(projectRoot, "public", "storage", "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() {
|
||||
const readablePath = existsSync(dataPath) ? dataPath : legacyPublicDataPath;
|
||||
|
|
@ -351,6 +361,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
}
|
||||
|
||||
async function updateSettings(payload, identity) {
|
||||
return enqueueMutation(async () => {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const patch = typeof payload === "object" && payload !== null ? payload : {};
|
||||
|
|
@ -378,6 +389,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
|
||||
await writeData(data);
|
||||
return { settings, data };
|
||||
});
|
||||
}
|
||||
|
||||
async function updateUserProfile(userId, payload, identity) {
|
||||
|
|
@ -1540,6 +1552,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
}
|
||||
|
||||
async function updateService(serviceId, payload, identity) {
|
||||
return enqueueMutation(async () => {
|
||||
const data = readData();
|
||||
const actor = resolveActor(data, identity);
|
||||
const service = findById(data.services, serviceId, "service");
|
||||
|
|
@ -1558,6 +1571,7 @@ export function createControlPlaneStore({ projectRoot }) {
|
|||
|
||||
await writeData(data);
|
||||
return { service, data };
|
||||
});
|
||||
}
|
||||
|
||||
async function reorderServices(payload, identity) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { existsSync, readFileSync } from "node:fs";
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { createAuthentikSyncClient, resolveRequiredGroups } from "./authentik-sync.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" });
|
||||
});
|
||||
|
||||
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,
|
||||
appType: "spa",
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: { server: httpServer },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.use(vite.middlewares);
|
||||
fixFrontendStacktrace = (error) => vite.ssrFixStacktrace(error);
|
||||
app.use(vite.middlewares);
|
||||
}
|
||||
|
||||
app.use((error, _req, res, _next) => {
|
||||
vite.ssrFixStacktrace(error);
|
||||
fixFrontendStacktrace(error);
|
||||
const message = error instanceof Error ? error.message : "Unexpected server error";
|
||||
res.status(500).json({ error: message });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ type InviteFlowState =
|
|||
| { status: "registered"; payload: PublicInviteResponse; loginUrl: string }
|
||||
| { status: "error"; message: string; payload?: PublicInviteResponse };
|
||||
|
||||
type ControlPlaneMutationOutcome = { ok: true; data: LauncherData } | { ok: false; message: string };
|
||||
|
||||
export function LauncherApp() {
|
||||
const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []);
|
||||
const isAccessRequestRoute = useMemo(() => isAccessRequestPath(window.location.pathname), []);
|
||||
|
|
@ -490,14 +492,16 @@ export function LauncherApp() {
|
|||
});
|
||||
}
|
||||
|
||||
function applyControlPlaneMutation(request: Promise<ControlPlaneMutationResult>) {
|
||||
request
|
||||
.then((result) => {
|
||||
async function applyControlPlaneMutation(request: Promise<ControlPlaneMutationResult>): Promise<ControlPlaneMutationOutcome> {
|
||||
try {
|
||||
const result = await request;
|
||||
setData(syncLauncherServiceLinks(result.data));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию");
|
||||
});
|
||||
return { ok: true, data: result.data };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "Не удалось выполнить admin API операцию";
|
||||
console.warn(message);
|
||||
return { ok: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTaskManagerWorkspaces() {
|
||||
|
|
@ -697,11 +701,11 @@ export function LauncherApp() {
|
|||
}
|
||||
|
||||
function handleUpdateSettings(patch: Partial<LauncherSettings>) {
|
||||
applyControlPlaneMutation(updateAdminSettings(patch));
|
||||
return applyControlPlaneMutation(updateAdminSettings(patch));
|
||||
}
|
||||
|
||||
function handleUpdateService(serviceId: string, patch: Partial<Service>) {
|
||||
applyControlPlaneMutation(updateAdminService(serviceId, patch));
|
||||
return applyControlPlaneMutation(updateAdminService(serviceId, patch));
|
||||
}
|
||||
|
||||
function handleCreateClient() {
|
||||
|
|
|
|||
|
|
@ -4237,6 +4237,17 @@ code {
|
|||
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 {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ type AdminSection =
|
|||
| "misc"
|
||||
| "company";
|
||||
type AdminOverlayMode = "admin" | "platform";
|
||||
type AdminMutationOutcome = { ok: true } | { ok: false; message: string };
|
||||
|
||||
type AccessAssignmentRole = Exclude<ServiceAppRole, "owner">;
|
||||
export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset";
|
||||
|
|
@ -229,11 +230,11 @@ export function AdminOverlay({
|
|||
onCreateGroup: (clientId: string) => void;
|
||||
onUpdateGroup: (groupId: string, patch: Partial<ClientGroup>) => void;
|
||||
onDeleteGroup: (groupId: string) => void;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
|
||||
onReorderServices: (orderedServiceIds: string[]) => void;
|
||||
onCreateService: () => void;
|
||||
onDeleteService: (serviceId: string) => void;
|
||||
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
||||
onUpdateSettings: (patch: Partial<LauncherSettings>) => Promise<AdminMutationOutcome>;
|
||||
taskManagerWorkspaces: TaskManagerWorkspaceSummary[];
|
||||
taskManagerWorkspacesLoading: boolean;
|
||||
taskManagerWorkspacesError: string | null;
|
||||
|
|
@ -1546,7 +1547,7 @@ function ServicesSection({
|
|||
}: {
|
||||
data: LauncherData;
|
||||
isPublicPoolContext: boolean;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
|
||||
onReorderServices: (orderedServiceIds: string[]) => void;
|
||||
onCreateService: () => void;
|
||||
onDeleteService: (serviceId: string) => void;
|
||||
|
|
@ -1653,9 +1654,12 @@ function ServicesSection({
|
|||
<ServiceContentModal
|
||||
service={contentService}
|
||||
onClose={() => setContentServiceId(null)}
|
||||
onSave={(patch) => {
|
||||
onUpdateService(contentService.id, patch);
|
||||
onSave={async (patch) => {
|
||||
const result = await onUpdateService(contentService.id, patch);
|
||||
if (result.ok) {
|
||||
setContentServiceId(null);
|
||||
}
|
||||
return result;
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDeleteService(contentService.id);
|
||||
|
|
@ -1687,7 +1691,7 @@ function SortableServiceRow({
|
|||
onOpenContent,
|
||||
}: {
|
||||
service: Service;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
|
||||
onOpenContent: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({ id: service.id });
|
||||
|
|
@ -1719,7 +1723,7 @@ function ServiceTableCells({
|
|||
setDragHandleRef,
|
||||
}: {
|
||||
service: Service;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => void;
|
||||
onUpdateService: (serviceId: string, patch: Partial<Service>) => Promise<AdminMutationOutcome>;
|
||||
onOpenContent: () => void;
|
||||
dragAttributes?: DraggableAttributes;
|
||||
dragListeners?: DraggableSyntheticListeners;
|
||||
|
|
@ -1927,7 +1931,7 @@ function ServiceContentModal({
|
|||
}: {
|
||||
service: Service;
|
||||
onClose: () => void;
|
||||
onSave: (patch: Partial<Service>) => void;
|
||||
onSave: (patch: Partial<Service>) => Promise<AdminMutationOutcome>;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Service>(service);
|
||||
|
|
@ -1937,6 +1941,8 @@ function ServiceContentModal({
|
|||
});
|
||||
const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | 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);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1946,6 +1952,8 @@ function ServiceContentModal({
|
|||
ambient: service.ambientVideoUrl ?? null,
|
||||
});
|
||||
setStorageError(null);
|
||||
setSaveError(null);
|
||||
setIsSaving(false);
|
||||
setUploadingSlot(null);
|
||||
}, [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 (
|
||||
<div className="service-content-modal-layer" role="dialog" aria-modal="true" aria-label={`Контент витрины ${service.title}`}>
|
||||
<article className="service-content-modal">
|
||||
|
|
@ -2091,6 +2126,7 @@ function ServiceContentModal({
|
|||
/>
|
||||
|
||||
{storageError ? <p className="service-content-storage-error">{storageError}</p> : null}
|
||||
{saveError ? <p className="service-content-storage-error">{saveError}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="service-content-modal__foot">
|
||||
|
|
@ -2106,28 +2142,11 @@ function ServiceContentModal({
|
|||
surface="modal"
|
||||
accentRgb={modalActionAccentRgb}
|
||||
type="button"
|
||||
disabled={uploadingSlot !== null}
|
||||
disabled={uploadingSlot !== null || isSaving}
|
||||
icon={<Save size={16} />}
|
||||
onClick={() =>
|
||||
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,
|
||||
})
|
||||
}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{uploadingSlot ? "Сохраняем файл" : "Сохранить"}
|
||||
{uploadingSlot ? "Сохраняем файл" : isSaving ? "Сохраняем" : "Сохранить"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4408,12 +4427,14 @@ function MiscSection({
|
|||
onUpdateSettings,
|
||||
}: {
|
||||
data: LauncherData;
|
||||
onUpdateSettings: (patch: Partial<LauncherSettings>) => void;
|
||||
onUpdateSettings: (patch: Partial<LauncherSettings>) => Promise<AdminMutationOutcome>;
|
||||
}) {
|
||||
const [logoLinkUrl, setLogoLinkUrl] = useState(data.settings.brand.logoLinkUrl);
|
||||
const [workspaceCreationPolicy, setWorkspaceCreationPolicy] = useState(
|
||||
data.settings.taskManager.workspaceCreationPolicy
|
||||
);
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLogoLinkUrl(data.settings.brand.logoLinkUrl);
|
||||
|
|
@ -4425,6 +4446,25 @@ function MiscSection({
|
|||
normalizedLogoLinkUrl !== data.settings.brand.logoLinkUrl ||
|
||||
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 (
|
||||
<GlassSurface className="table-shell admin-settings-panel">
|
||||
<div className="table-toolbar">
|
||||
|
|
@ -4438,17 +4478,17 @@ function MiscSection({
|
|||
variant="accent"
|
||||
type="button"
|
||||
icon={<Save size={16} />}
|
||||
disabled={!hasChanges}
|
||||
onClick={() =>
|
||||
onUpdateSettings({
|
||||
brand: { logoLinkUrl: normalizedLogoLinkUrl },
|
||||
taskManager: { workspaceCreationPolicy },
|
||||
})
|
||||
}
|
||||
disabled={!hasChanges || saveState === "saving"}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Сохранить
|
||||
{saveState === "saving" ? "Сохраняем" : "Сохранить"}
|
||||
</Button>
|
||||
</div>
|
||||
{saveMessage ? (
|
||||
<p className={cn("admin-settings-save-message", saveState === "error" && "admin-settings-save-message--error")}>
|
||||
{saveMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="admin-settings-grid">
|
||||
<label className="admin-settings-field">
|
||||
|
|
@ -4457,7 +4497,11 @@ function MiscSection({
|
|||
className="admin-table-input admin-settings-field__input"
|
||||
value={logoLinkUrl}
|
||||
placeholder="/"
|
||||
onChange={(event) => setLogoLinkUrl(event.target.value)}
|
||||
onChange={(event) => {
|
||||
setLogoLinkUrl(event.target.value);
|
||||
setSaveState("idle");
|
||||
setSaveMessage(null);
|
||||
}}
|
||||
/>
|
||||
<small>Куда ведёт клик по логотипу NODE.DC. Можно указать относительный путь или полный URL.</small>
|
||||
</label>
|
||||
|
|
@ -4469,7 +4513,11 @@ function MiscSection({
|
|||
value={workspaceCreationPolicy}
|
||||
options={taskManagerWorkspacePolicyOptions}
|
||||
label="Политика создания workspace в Operational Core"
|
||||
onChange={(value) => setWorkspaceCreationPolicy(value)}
|
||||
onChange={(value) => {
|
||||
setWorkspaceCreationPolicy(value);
|
||||
setSaveState("idle");
|
||||
setSaveMessage(null);
|
||||
}}
|
||||
/>
|
||||
<small>Платформенная policy для новых пользователей без назначенных рабочих пространств в Task Manager.</small>
|
||||
</label>
|
||||
|
|
|
|||
Loading…
Reference in New Issue