perf: optimize launcher boot and media loading
This commit is contained in:
parent
3799483638
commit
3094464f62
|
|
@ -1543,7 +1543,19 @@ if (process.env.NODE_ENV === "production") {
|
|||
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(express.static(distRoot, {
|
||||
index: false,
|
||||
immutable: true,
|
||||
maxAge: "1y",
|
||||
setHeaders(res, assetPath) {
|
||||
if (assetPath === indexHtmlPath) {
|
||||
res.setHeader("Cache-Control", noStoreCacheControl);
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
},
|
||||
}));
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Client } from "../entities/client/types";
|
||||
import type { Invite } from "../entities/invite/types";
|
||||
import { syncServiceLaunchLink } from "../entities/service/links";
|
||||
|
|
@ -21,7 +21,6 @@ import {
|
|||
ensureAdminTaskManagerProjectMembership,
|
||||
ensureAdminTaskManagerWorkspaceMembership,
|
||||
fetchAdminTaskManagerWorkspaces,
|
||||
fetchControlPlaneSnapshot,
|
||||
reorderAdminServices,
|
||||
retryAdminSync,
|
||||
rejectAdminAccessRequest,
|
||||
|
|
@ -64,21 +63,24 @@ import { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteRespo
|
|||
import type { AccessRequest, CreateAccessRequestCommand } from "../entities/access-request/types";
|
||||
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
||||
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
||||
import {
|
||||
AdminOverlay,
|
||||
type AccessAssignmentValue,
|
||||
type CreateUserCommand,
|
||||
type EnsureTaskManagerProjectMemberCommand,
|
||||
type SetServiceModuleEntitlementCommand,
|
||||
type SetUserServiceAccessCommand,
|
||||
import type {
|
||||
AccessAssignmentValue,
|
||||
CreateUserCommand,
|
||||
EnsureTaskManagerProjectMemberCommand,
|
||||
SetServiceModuleEntitlementCommand,
|
||||
SetUserServiceAccessCommand,
|
||||
} from "../widgets/admin-overlay/AdminOverlay";
|
||||
import { ProfileSettingsPanel } from "../widgets/profile-settings-panel/ProfileSettingsPanel";
|
||||
import { ServiceRail } from "../widgets/service-rail/ServiceRail";
|
||||
import { ServiceStage } from "../widgets/service-stage/ServiceStage";
|
||||
import { TopBar, type LauncherAdminMode } from "../widgets/top-bar/TopBar";
|
||||
|
||||
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
||||
|
||||
const AdminOverlay = lazy(() => import("../widgets/admin-overlay/AdminOverlay").then((module) => ({ default: module.AdminOverlay })));
|
||||
const ProfileSettingsPanel = lazy(() =>
|
||||
import("../widgets/profile-settings-panel/ProfileSettingsPanel").then((module) => ({ default: module.ProfileSettingsPanel }))
|
||||
);
|
||||
|
||||
type InviteFlowState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; payload: PublicInviteResponse }
|
||||
|
|
@ -101,6 +103,7 @@ export function LauncherApp() {
|
|||
const [adminMode, setAdminMode] = useState<LauncherAdminMode>("admin");
|
||||
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
||||
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
||||
const [runtimeReady, setRuntimeReady] = useState(false);
|
||||
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
||||
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
||||
const [pendingServiceModuleEntitlements, setPendingServiceModuleEntitlements] = useState<Record<string, boolean>>({});
|
||||
|
|
@ -113,6 +116,7 @@ export function LauncherApp() {
|
|||
const runtimeDataRef = useRef(data);
|
||||
const runtimeProfileIdRef = useRef(activeProfileId);
|
||||
const runtimeClientIdRef = useRef(activeClientId);
|
||||
const runtimeRefreshInFlightRef = useRef<Promise<void> | null>(null);
|
||||
const resolvedProfileId = useMemo(
|
||||
() => resolveRuntimeProfileId(data, authSession, activeProfileId),
|
||||
[activeProfileId, authSession, data]
|
||||
|
|
@ -219,38 +223,63 @@ export function LauncherApp() {
|
|||
|
||||
const selectedService = launcherServices.find((service) => service.id === selectedServiceId);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const refreshRuntimeState = useCallback(async () => {
|
||||
if (runtimeRefreshInFlightRef.current) {
|
||||
return runtimeRefreshInFlightRef.current;
|
||||
}
|
||||
|
||||
fetchAuthSession()
|
||||
.then(async (session) => {
|
||||
if (!isMounted) return;
|
||||
const request = (async () => {
|
||||
try {
|
||||
const nextSession = await fetchAuthSession();
|
||||
|
||||
setAuthSession(session);
|
||||
setAuthSession(nextSession);
|
||||
|
||||
if (!session.authenticated) {
|
||||
if (!nextSession.authenticated) {
|
||||
setAuthApps([]);
|
||||
setRuntimeReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const apps = await fetchAvailableApps();
|
||||
const [persistedData, apps] = await Promise.all([
|
||||
loadPersistedLauncherData(),
|
||||
fetchAvailableApps(),
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setAuthApps(apps);
|
||||
if (persistedData) {
|
||||
setData(syncLauncherServiceLinks(persistedData));
|
||||
}
|
||||
})
|
||||
|
||||
setAuthApps(apps);
|
||||
setRuntimeReady(true);
|
||||
} catch (error: unknown) {
|
||||
setRuntimeReady(true);
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
||||
}
|
||||
})().finally(() => {
|
||||
runtimeRefreshInFlightRef.current = null;
|
||||
});
|
||||
|
||||
runtimeRefreshInFlightRef.current = request;
|
||||
return request;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
refreshRuntimeState()
|
||||
.catch((error: unknown) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
setAuthSession({ authenticated: false, loginUrl: "/auth/login" });
|
||||
setAuthApps([]);
|
||||
setRuntimeReady(true);
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось проверить сессию платформы");
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
}, [refreshRuntimeState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession || authSession.authenticated) return;
|
||||
|
|
@ -338,41 +367,6 @@ export function LauncherApp() {
|
|||
}
|
||||
}, [activeClientId, activeProfileId, authSession, data]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
loadPersistedLauncherData()
|
||||
.then((persistedData) => {
|
||||
if (isMounted && persistedData) {
|
||||
setData(syncLauncherServiceLinks(persistedData));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canOpenAdminApi) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
fetchControlPlaneSnapshot()
|
||||
.then((snapshot) => {
|
||||
if (isMounted) {
|
||||
setData(syncLauncherServiceLinks(snapshot.data));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось загрузить control-plane snapshot");
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [canOpenAdminApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminOpen || !canOpenAdminApi) return;
|
||||
void refreshTaskManagerWorkspaces();
|
||||
|
|
@ -385,32 +379,6 @@ export function LauncherApp() {
|
|||
setAdminMode("admin");
|
||||
}, [runtimeMe.permissions.canOpenAdmin]);
|
||||
|
||||
const refreshRuntimeState = useCallback(async () => {
|
||||
try {
|
||||
const nextSession = await fetchAuthSession();
|
||||
|
||||
setAuthSession(nextSession);
|
||||
|
||||
if (!nextSession.authenticated) {
|
||||
setAuthApps([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const [persistedData, apps] = await Promise.all([
|
||||
loadPersistedLauncherData(),
|
||||
fetchAvailableApps(),
|
||||
]);
|
||||
|
||||
if (persistedData) {
|
||||
setData(syncLauncherServiceLinks(persistedData));
|
||||
}
|
||||
|
||||
setAuthApps(apps);
|
||||
} catch (error: unknown) {
|
||||
console.warn(error instanceof Error ? error.message : "Не удалось обновить runtime состояние Launcher");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.authenticated) return;
|
||||
|
||||
|
|
@ -423,10 +391,6 @@ export function LauncherApp() {
|
|||
|
||||
const eventSource = new EventSource("/api/events");
|
||||
|
||||
eventSource.addEventListener("nodedc-ready", () => {
|
||||
void refreshMountedRuntimeState();
|
||||
});
|
||||
|
||||
eventSource.addEventListener("nodedc-runtime", () => {
|
||||
void refreshMountedRuntimeState();
|
||||
});
|
||||
|
|
@ -466,7 +430,7 @@ export function LauncherApp() {
|
|||
if (document.visibilityState === "visible") {
|
||||
void refreshRuntimeState();
|
||||
}
|
||||
}, 5000);
|
||||
}, 60000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
|
|
@ -871,7 +835,7 @@ export function LauncherApp() {
|
|||
);
|
||||
}
|
||||
|
||||
if (!authSession) {
|
||||
if (!authSession || (authSession.authenticated && !runtimeReady)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -923,58 +887,62 @@ export function LauncherApp() {
|
|||
onSelectNext={() => handleStageStep("next")}
|
||||
/>
|
||||
{adminOpen && me.permissions.canOpenAdmin ? (
|
||||
<AdminOverlay
|
||||
data={data}
|
||||
me={runtimeMe}
|
||||
mode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
||||
activeClientId={resolvedClientId}
|
||||
onClose={() => setAdminOpen(false)}
|
||||
onSetUserServiceAccess={handleSetUserServiceAccess}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
onUpdateInvite={handleUpdateInvite}
|
||||
onDeleteInvite={handleDeleteInvite}
|
||||
onUpdateAccessRequest={handleUpdateAccessRequest}
|
||||
onApproveAccessRequest={handleApproveAccessRequest}
|
||||
onRejectAccessRequest={handleRejectAccessRequest}
|
||||
onApproveTaskerInviteRequest={handleApproveTaskerInviteRequest}
|
||||
onRejectTaskerInviteRequest={handleRejectTaskerInviteRequest}
|
||||
onRetrySync={handleRetrySync}
|
||||
onCreateClient={handleCreateClient}
|
||||
onUpdateClient={handleUpdateClient}
|
||||
onDeleteClient={handleDeleteClient}
|
||||
onCreateUser={handleCreateUser}
|
||||
onUpdateUser={handleUpdateUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onUpdateMembership={handleUpdateMembership}
|
||||
onDeleteMembership={handleDeleteMembership}
|
||||
pendingAccessAssignments={pendingAccessAssignments}
|
||||
pendingServiceModuleEntitlements={pendingServiceModuleEntitlements}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onUpdateGroup={handleUpdateGroup}
|
||||
onDeleteGroup={handleDeleteGroup}
|
||||
onUpdateService={handleUpdateService}
|
||||
onReorderServices={handleReorderServices}
|
||||
onCreateService={handleCreateService}
|
||||
onDeleteService={handleDeleteService}
|
||||
onUpdateSettings={handleUpdateSettings}
|
||||
taskManagerWorkspaces={taskManagerWorkspaces}
|
||||
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
|
||||
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||||
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||||
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
|
||||
onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()}
|
||||
onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole}
|
||||
onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole}
|
||||
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<AdminOverlay
|
||||
data={data}
|
||||
me={runtimeMe}
|
||||
mode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
||||
activeClientId={resolvedClientId}
|
||||
onClose={() => setAdminOpen(false)}
|
||||
onSetUserServiceAccess={handleSetUserServiceAccess}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
onUpdateInvite={handleUpdateInvite}
|
||||
onDeleteInvite={handleDeleteInvite}
|
||||
onUpdateAccessRequest={handleUpdateAccessRequest}
|
||||
onApproveAccessRequest={handleApproveAccessRequest}
|
||||
onRejectAccessRequest={handleRejectAccessRequest}
|
||||
onApproveTaskerInviteRequest={handleApproveTaskerInviteRequest}
|
||||
onRejectTaskerInviteRequest={handleRejectTaskerInviteRequest}
|
||||
onRetrySync={handleRetrySync}
|
||||
onCreateClient={handleCreateClient}
|
||||
onUpdateClient={handleUpdateClient}
|
||||
onDeleteClient={handleDeleteClient}
|
||||
onCreateUser={handleCreateUser}
|
||||
onUpdateUser={handleUpdateUser}
|
||||
onDeleteUser={handleDeleteUser}
|
||||
onUpdateMembership={handleUpdateMembership}
|
||||
onDeleteMembership={handleDeleteMembership}
|
||||
pendingAccessAssignments={pendingAccessAssignments}
|
||||
pendingServiceModuleEntitlements={pendingServiceModuleEntitlements}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onUpdateGroup={handleUpdateGroup}
|
||||
onDeleteGroup={handleDeleteGroup}
|
||||
onUpdateService={handleUpdateService}
|
||||
onReorderServices={handleReorderServices}
|
||||
onCreateService={handleCreateService}
|
||||
onDeleteService={handleDeleteService}
|
||||
onUpdateSettings={handleUpdateSettings}
|
||||
taskManagerWorkspaces={taskManagerWorkspaces}
|
||||
taskManagerWorkspacesLoading={taskManagerWorkspacesLoading}
|
||||
taskManagerWorkspacesError={taskManagerWorkspacesError}
|
||||
pendingTaskManagerMemberships={pendingTaskManagerMemberships}
|
||||
pendingTaskManagerProjectMemberships={pendingTaskManagerProjectMemberships}
|
||||
onRefreshTaskManagerWorkspaces={() => void refreshTaskManagerWorkspaces()}
|
||||
onSetTaskManagerWorkspaceMemberRole={handleSetTaskManagerWorkspaceMemberRole}
|
||||
onSetTaskManagerProjectMemberRole={handleSetTaskManagerProjectMemberRole}
|
||||
onSetServiceModuleEntitlement={handleSetServiceModuleEntitlement}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{profileSettingsOpen && activeProfileUser ? (
|
||||
<ProfileSettingsPanel
|
||||
user={activeProfileUser}
|
||||
onClose={() => setProfileSettingsOpen(false)}
|
||||
onSaveProfile={handleUpdateOwnProfile}
|
||||
onChangePassword={handleUpdateOwnPassword}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<ProfileSettingsPanel
|
||||
user={activeProfileUser}
|
||||
onClose={() => setProfileSettingsOpen(false)}
|
||||
onSaveProfile={handleUpdateOwnProfile}
|
||||
onChangePassword={handleUpdateOwnPassword}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
<ServiceRail services={launcherServices} selectedServiceId={selectedServiceId} onSelect={handleServiceSelect} />
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -754,7 +754,6 @@ code {
|
|||
content: "";
|
||||
opacity: 0.34;
|
||||
transform: rotate(-2deg);
|
||||
animation: stageVideoDrift 16s linear infinite;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
|
|
@ -1045,15 +1044,6 @@ code {
|
|||
background: color-mix(in srgb, var(--service-accent) 45%, rgba(255, 255, 255, 0.16));
|
||||
}
|
||||
|
||||
@keyframes stageVideoDrift {
|
||||
from {
|
||||
transform: translate3d(0, 0, 0) rotate(-2deg);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(-7rem, -7rem, 0) rotate(-2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stageFigureFloat {
|
||||
from {
|
||||
transform: translate3d(-0.8rem, 0, 0) rotate(-9deg) scale(1);
|
||||
|
|
@ -1201,7 +1191,7 @@ code {
|
|||
flex: 0 0 var(--service-rail-card-size);
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: color-mix(in srgb, var(--tile-accent) 36%, rgba(255, 255, 255, 0.12));
|
||||
background: #050506;
|
||||
}
|
||||
|
||||
.service-tile__media-asset {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ChevronRight } from "lucide-react";
|
||||
import { DEFAULT_AMBIENT_MEDIA, resolveAmbientMedia } from "../../entities/service/media";
|
||||
import { resolveAmbientMedia } from "../../entities/service/media";
|
||||
import type { LauncherServiceView } from "../../entities/service/types";
|
||||
import { cn } from "../../shared/lib/cn";
|
||||
|
||||
|
|
@ -34,8 +34,8 @@ export function ServiceRail({
|
|||
>
|
||||
<RailMedia
|
||||
className="service-tile__media-asset"
|
||||
src={service.media.coverImage ?? service.media.ambientVideo ?? DEFAULT_AMBIENT_MEDIA}
|
||||
kind={service.media.coverKind ?? service.media.ambientKind}
|
||||
src={service.media.coverImage ?? service.media.thumbnail ?? null}
|
||||
kind={service.media.coverKind ?? "image"}
|
||||
/>
|
||||
</span>
|
||||
<span className="service-tile__content">
|
||||
|
|
@ -57,15 +57,17 @@ function RailMedia({
|
|||
kind,
|
||||
className,
|
||||
}: {
|
||||
src: string;
|
||||
src?: string | null;
|
||||
kind?: LauncherServiceView["media"]["coverKind"];
|
||||
className: string;
|
||||
}) {
|
||||
if (!src) return null;
|
||||
|
||||
if (kind === "video" || isVideoSource(src)) {
|
||||
return <video className={className} src={src} autoPlay loop muted playsInline />;
|
||||
return <video className={className} src={src} autoPlay loop muted playsInline preload="metadata" />;
|
||||
}
|
||||
|
||||
return <img className={className} src={src} alt="" />;
|
||||
return <img className={className} src={src} alt="" loading="lazy" decoding="async" />;
|
||||
}
|
||||
|
||||
function isVideoSource(src: string) {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ export function ServiceStage({
|
|||
<section className="service-stage" style={style}>
|
||||
<div className="stage-video-shell">
|
||||
<div className="stage-video-stream" aria-hidden="true">
|
||||
<StageMedia className="stage-video-gif" src={ambientMedia.src} kind={ambientMedia.kind} />
|
||||
<StageMedia className="stage-video-gif" src={ambientMedia.src} kind={ambientMedia.kind} priority={!service} />
|
||||
</div>
|
||||
|
||||
{service ? (
|
||||
|
|
@ -155,12 +155,22 @@ function ServiceIcon({ slug }: { slug: string }) {
|
|||
return <Bot size={18} />;
|
||||
}
|
||||
|
||||
function StageMedia({ src, kind, className }: { src: string; kind?: LauncherServiceView["media"]["coverKind"]; className: string }) {
|
||||
function StageMedia({
|
||||
src,
|
||||
kind,
|
||||
className,
|
||||
priority = false,
|
||||
}: {
|
||||
src: string;
|
||||
kind?: LauncherServiceView["media"]["coverKind"];
|
||||
className: string;
|
||||
priority?: boolean;
|
||||
}) {
|
||||
if (kind === "video" || isVideoSource(src)) {
|
||||
return <video className={className} src={src} autoPlay loop muted playsInline />;
|
||||
return <video className={className} src={src} autoPlay loop muted playsInline preload="metadata" />;
|
||||
}
|
||||
|
||||
return <img className={className} src={src} alt="" />;
|
||||
return <img className={className} src={src} alt="" loading={priority ? "eager" : "lazy"} decoding="async" />;
|
||||
}
|
||||
|
||||
function RichDescription({ text }: { text: string }) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue