diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ce2766c --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3b23604 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index beccee3..6807eae 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.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,33 +361,35 @@ export function createControlPlaneStore({ projectRoot }) { } async function updateSettings(payload, identity) { - const data = readData(); - const actor = resolveActor(data, identity); - const patch = typeof payload === "object" && payload !== null ? payload : {}; - const settings = normalizeSettings({ - ...data.settings, - ...patch, - brand: { - ...(data.settings?.brand ?? {}), - ...(patch.brand ?? {}), - }, - taskManager: { - ...(data.settings?.taskManager ?? {}), - ...(patch.taskManager ?? {}), - }, - }); + return enqueueMutation(async () => { + const data = readData(); + const actor = resolveActor(data, identity); + const patch = typeof payload === "object" && payload !== null ? payload : {}; + const settings = normalizeSettings({ + ...data.settings, + ...patch, + brand: { + ...(data.settings?.brand ?? {}), + ...(patch.brand ?? {}), + }, + taskManager: { + ...(data.settings?.taskManager ?? {}), + ...(patch.taskManager ?? {}), + }, + }); - data.settings = settings; - addAuditEvent(data, actor, { - action: "Обновлены системные настройки", - objectType: "settings", - objectName: "Brand settings", - result: "success", - details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`, - }); + data.settings = settings; + addAuditEvent(data, actor, { + action: "Обновлены системные настройки", + objectType: "settings", + objectName: "Brand settings", + result: "success", + details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`, + }); - await writeData(data); - return { settings, data }; + await writeData(data); + return { settings, data }; + }); } async function updateUserProfile(userId, payload, identity) { @@ -1540,24 +1552,26 @@ export function createControlPlaneStore({ projectRoot }) { } async function updateService(serviceId, payload, identity) { - const data = readData(); - const actor = resolveActor(data, identity); - const service = findById(data.services, serviceId, "service"); + return enqueueMutation(async () => { + const data = readData(); + const actor = resolveActor(data, identity); + const service = findById(data.services, serviceId, "service"); - Object.assign(service, sanitizeServicePatch(payload, service)); - Object.assign(service, syncServiceLaunchLink(service)); - service.updatedAt = isoNow(); + Object.assign(service, sanitizeServicePatch(payload, service)); + Object.assign(service, syncServiceLaunchLink(service)); + service.updatedAt = isoNow(); - addAuditEvent(data, actor, { - action: "Обновлён сервис", - objectType: "service", - objectName: service.title, - result: "success", + addAuditEvent(data, actor, { + action: "Обновлён сервис", + objectType: "service", + objectName: service.title, + result: "success", + }); + markPendingSync(data, service, "service"); + + await writeData(data); + return { service, data }; }); - markPendingSync(data, service, "service"); - - await writeData(data); - return { service, data }; } async function reorderServices(payload, identity) { diff --git a/server/dev-server.mjs b/server/dev-server.mjs index a37168c..1dbf7bf 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -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({ - root: projectRoot, - appType: "spa", - server: { - middlewareMode: true, - hmr: { server: httpServer }, - }, -}); +let fixFrontendStacktrace = () => {}; -app.use(vite.middlewares); +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 }, + }, + }); + + 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 }); }); diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index e67b967..3e40b9b 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -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) { - request - .then((result) => { - setData(syncLauncherServiceLinks(result.data)); - }) - .catch((error: unknown) => { - console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию"); - }); + async function applyControlPlaneMutation(request: Promise): Promise { + try { + const result = await request; + setData(syncLauncherServiceLinks(result.data)); + 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) { - applyControlPlaneMutation(updateAdminSettings(patch)); + return applyControlPlaneMutation(updateAdminSettings(patch)); } function handleUpdateService(serviceId: string, patch: Partial) { - applyControlPlaneMutation(updateAdminService(serviceId, patch)); + return applyControlPlaneMutation(updateAdminService(serviceId, patch)); } function handleCreateClient() { diff --git a/src/styles/globals.css b/src/styles/globals.css index f61f44c..4ab76dc 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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; diff --git a/src/widgets/admin-overlay/AdminOverlay.tsx b/src/widgets/admin-overlay/AdminOverlay.tsx index 437f46b..4278754 100644 --- a/src/widgets/admin-overlay/AdminOverlay.tsx +++ b/src/widgets/admin-overlay/AdminOverlay.tsx @@ -90,6 +90,7 @@ type AdminSection = | "misc" | "company"; type AdminOverlayMode = "admin" | "platform"; +type AdminMutationOutcome = { ok: true } | { ok: false; message: string }; type AccessAssignmentRole = Exclude; export type AccessAssignmentValue = AccessAssignmentRole | "deny" | "unset"; @@ -229,11 +230,11 @@ export function AdminOverlay({ onCreateGroup: (clientId: string) => void; onUpdateGroup: (groupId: string, patch: Partial) => void; onDeleteGroup: (groupId: string) => void; - onUpdateService: (serviceId: string, patch: Partial) => void; + onUpdateService: (serviceId: string, patch: Partial) => Promise; onReorderServices: (orderedServiceIds: string[]) => void; onCreateService: () => void; onDeleteService: (serviceId: string) => void; - onUpdateSettings: (patch: Partial) => void; + onUpdateSettings: (patch: Partial) => Promise; taskManagerWorkspaces: TaskManagerWorkspaceSummary[]; taskManagerWorkspacesLoading: boolean; taskManagerWorkspacesError: string | null; @@ -1546,7 +1547,7 @@ function ServicesSection({ }: { data: LauncherData; isPublicPoolContext: boolean; - onUpdateService: (serviceId: string, patch: Partial) => void; + onUpdateService: (serviceId: string, patch: Partial) => Promise; onReorderServices: (orderedServiceIds: string[]) => void; onCreateService: () => void; onDeleteService: (serviceId: string) => void; @@ -1653,9 +1654,12 @@ function ServicesSection({ setContentServiceId(null)} - onSave={(patch) => { - onUpdateService(contentService.id, patch); - setContentServiceId(null); + 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) => void; + onUpdateService: (serviceId: string, patch: Partial) => Promise; 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) => void; + onUpdateService: (serviceId: string, patch: Partial) => Promise; onOpenContent: () => void; dragAttributes?: DraggableAttributes; dragListeners?: DraggableSyntheticListeners; @@ -1927,7 +1931,7 @@ function ServiceContentModal({ }: { service: Service; onClose: () => void; - onSave: (patch: Partial) => void; + onSave: (patch: Partial) => Promise; onDelete: () => void; }) { const [draft, setDraft] = useState(service); @@ -1937,6 +1941,8 @@ function ServiceContentModal({ }); const [uploadingSlot, setUploadingSlot] = useState<"cover" | "ambient" | null>(null); const [storageError, setStorageError] = useState(null); + const [saveError, setSaveError] = useState(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 (
@@ -2091,6 +2126,7 @@ function ServiceContentModal({ /> {storageError ?

{storageError}

: null} + {saveError ?

{saveError}

: null}
@@ -2106,28 +2142,11 @@ function ServiceContentModal({ surface="modal" accentRgb={modalActionAccentRgb} type="button" - disabled={uploadingSlot !== null} + disabled={uploadingSlot !== null || isSaving} icon={} - 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 ? "Сохраняем" : "Сохранить"}
@@ -4408,12 +4427,14 @@ function MiscSection({ onUpdateSettings, }: { data: LauncherData; - onUpdateSettings: (patch: Partial) => void; + onUpdateSettings: (patch: Partial) => Promise; }) { 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(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 (
@@ -4438,17 +4478,17 @@ function MiscSection({ variant="accent" type="button" icon={} - disabled={!hasChanges} - onClick={() => - onUpdateSettings({ - brand: { logoLinkUrl: normalizedLogoLinkUrl }, - taskManager: { workspaceCreationPolicy }, - }) - } + disabled={!hasChanges || saveState === "saving"} + onClick={handleSave} > - Сохранить + {saveState === "saving" ? "Сохраняем" : "Сохранить"}
+ {saveMessage ? ( +

+ {saveMessage} +

+ ) : null}
@@ -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); + }} /> Платформенная policy для новых пользователей без назначенных рабочих пространств в Task Manager.