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 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,33 +361,35 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSettings(payload, identity) {
|
async function updateSettings(payload, identity) {
|
||||||
const data = readData();
|
return enqueueMutation(async () => {
|
||||||
const actor = resolveActor(data, identity);
|
const data = readData();
|
||||||
const patch = typeof payload === "object" && payload !== null ? payload : {};
|
const actor = resolveActor(data, identity);
|
||||||
const settings = normalizeSettings({
|
const patch = typeof payload === "object" && payload !== null ? payload : {};
|
||||||
...data.settings,
|
const settings = normalizeSettings({
|
||||||
...patch,
|
...data.settings,
|
||||||
brand: {
|
...patch,
|
||||||
...(data.settings?.brand ?? {}),
|
brand: {
|
||||||
...(patch.brand ?? {}),
|
...(data.settings?.brand ?? {}),
|
||||||
},
|
...(patch.brand ?? {}),
|
||||||
taskManager: {
|
},
|
||||||
...(data.settings?.taskManager ?? {}),
|
taskManager: {
|
||||||
...(patch.taskManager ?? {}),
|
...(data.settings?.taskManager ?? {}),
|
||||||
},
|
...(patch.taskManager ?? {}),
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
data.settings = settings;
|
data.settings = settings;
|
||||||
addAuditEvent(data, actor, {
|
addAuditEvent(data, actor, {
|
||||||
action: "Обновлены системные настройки",
|
action: "Обновлены системные настройки",
|
||||||
objectType: "settings",
|
objectType: "settings",
|
||||||
objectName: "Brand settings",
|
objectName: "Brand settings",
|
||||||
result: "success",
|
result: "success",
|
||||||
details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`,
|
details: `Logo link: ${settings.brand.logoLinkUrl}; Tasker workspace policy: ${settings.taskManager.workspaceCreationPolicy}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
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,24 +1552,26 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateService(serviceId, payload, identity) {
|
async function updateService(serviceId, payload, identity) {
|
||||||
const data = readData();
|
return enqueueMutation(async () => {
|
||||||
const actor = resolveActor(data, identity);
|
const data = readData();
|
||||||
const service = findById(data.services, serviceId, "service");
|
const actor = resolveActor(data, identity);
|
||||||
|
const service = findById(data.services, serviceId, "service");
|
||||||
|
|
||||||
Object.assign(service, sanitizeServicePatch(payload, service));
|
Object.assign(service, sanitizeServicePatch(payload, service));
|
||||||
Object.assign(service, syncServiceLaunchLink(service));
|
Object.assign(service, syncServiceLaunchLink(service));
|
||||||
service.updatedAt = isoNow();
|
service.updatedAt = isoNow();
|
||||||
|
|
||||||
addAuditEvent(data, actor, {
|
addAuditEvent(data, actor, {
|
||||||
action: "Обновлён сервис",
|
action: "Обновлён сервис",
|
||||||
objectType: "service",
|
objectType: "service",
|
||||||
objectName: service.title,
|
objectName: service.title,
|
||||||
result: "success",
|
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) {
|
async function reorderServices(payload, identity) {
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {};
|
||||||
root: projectRoot,
|
|
||||||
appType: "spa",
|
|
||||||
server: {
|
|
||||||
middlewareMode: true,
|
|
||||||
hmr: { server: httpServer },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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) => {
|
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 });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
setContentServiceId(null);
|
if (result.ok) {
|
||||||
|
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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue