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
+14 -10
View File
@@ -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) => {
setData(syncLauncherServiceLinks(result.data));
})
.catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : "Не удалось выполнить admin API операцию");
});
async function applyControlPlaneMutation(request: Promise<ControlPlaneMutationResult>): Promise<ControlPlaneMutationOutcome> {
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<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() {
+11
View File
@@ -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;
+88 -40
View File
@@ -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);
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<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>