568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
import { CssBaseline } from "@mui/material";
|
|
import { StyledEngineProvider, ThemeProvider } from "@mui/material/styles";
|
|
import dayjs from "dayjs";
|
|
import duration from "dayjs/plugin/duration";
|
|
import React, { Suspense, useEffect, useState } from "react";
|
|
import { HashRouter, Navigate, Route, Routes } from "react-router-dom";
|
|
import {
|
|
authenticateWithToken,
|
|
getAuthenticationMode,
|
|
} from "./authentication/authentication";
|
|
import { AUTHENTICATION_ERROR_EVENT } from "./authentication/constants";
|
|
import TokenAuthenticationDialog from "./authentication/TokenAuthenticationDialog";
|
|
import ActorDetailPage, { ActorDetailLayout } from "./pages/actor/ActorDetail";
|
|
import { ActorLayout } from "./pages/actor/ActorLayout";
|
|
import PlatformEventsPage from "./pages/events/PlatformEventsPage";
|
|
import Loading from "./pages/exception/Loading";
|
|
import JobList, { JobsLayout } from "./pages/job";
|
|
import { JobDetailChartsPage } from "./pages/job/JobDetail";
|
|
import {
|
|
JobDetailActorDetailWrapper,
|
|
JobDetailActorsPage,
|
|
} from "./pages/job/JobDetailActorPage";
|
|
import { JobDetailInfoPage } from "./pages/job/JobDetailInfoPage";
|
|
import { JobDetailLayout, JobPage } from "./pages/job/JobDetailLayout";
|
|
import { MainNavLayout } from "./pages/layout/MainNavLayout";
|
|
import { SideTabPage } from "./pages/layout/SideTabLayout";
|
|
import {
|
|
LogsLayout,
|
|
StateApiLogsListPage,
|
|
StateApiLogViewerPage,
|
|
} from "./pages/log/Logs";
|
|
import { Metrics } from "./pages/metrics";
|
|
import {
|
|
DashboardUids,
|
|
getMetricsInfo,
|
|
getTimeZoneInfo,
|
|
TimezoneInfo,
|
|
} from "./pages/metrics/utils";
|
|
import Nodes, { ClusterMainPageLayout } from "./pages/node";
|
|
import { ClusterDetailInfoPage } from "./pages/node/ClusterDetailInfoPage";
|
|
import { ClusterLayout } from "./pages/node/ClusterLayout";
|
|
import NodeDetailPage from "./pages/node/NodeDetail";
|
|
import { OverviewPage } from "./pages/overview/OverviewPage";
|
|
import {
|
|
ServeApplicationDetailLayout,
|
|
ServeApplicationDetailPage,
|
|
} from "./pages/serve/ServeApplicationDetailPage";
|
|
import {
|
|
ServeDeploymentDetailLayout,
|
|
ServeDeploymentDetailPage,
|
|
} from "./pages/serve/ServeDeploymentDetailPage";
|
|
import { ServeDeploymentsListPage } from "./pages/serve/ServeDeploymentsListPage";
|
|
import { ServeLayout, ServeSideTabLayout } from "./pages/serve/ServeLayout";
|
|
import { ServeReplicaDetailLayout } from "./pages/serve/ServeReplicaDetailLayout";
|
|
import { ServeReplicaDetailPage } from "./pages/serve/ServeReplicaDetailPage";
|
|
import {
|
|
ServeControllerDetailPage,
|
|
ServeProxyDetailPage,
|
|
} from "./pages/serve/ServeSystemActorDetailPage";
|
|
import {
|
|
ServeSystemDetailLayout,
|
|
ServeSystemDetailPage,
|
|
} from "./pages/serve/ServeSystemDetailPage";
|
|
import { TaskPage } from "./pages/task/TaskPage";
|
|
import { getNodeList } from "./service/node";
|
|
import { getPlatformEventsEnabled } from "./service/platform";
|
|
import { darkTheme, lightTheme } from "./theme";
|
|
|
|
dayjs.extend(duration);
|
|
|
|
// lazy loading fro prevent loading too much code at once
|
|
const Actors = React.lazy(() => import("./pages/actor"));
|
|
const CMDResult = React.lazy(() => import("./pages/cmd/CMDResult"));
|
|
|
|
// a global map for relations
|
|
export type GlobalContextType = {
|
|
nodeMap: { [key: string]: string };
|
|
nodeMapByIp: { [key: string]: string };
|
|
namespaceMap: { [key: string]: string[] };
|
|
/**
|
|
* Whether the initial metrics context has been fetched or not.
|
|
* This can be used to determine the difference between Grafana
|
|
* not being set up vs the status not being fetched yet.
|
|
*/
|
|
metricsContextLoaded: boolean;
|
|
/**
|
|
* The host that is serving grafana. Only set if grafana is
|
|
* running as detected by the grafana healthcheck endpoint.
|
|
*/
|
|
grafanaHost: string | undefined;
|
|
/**
|
|
* The param 'orgId' used in grafana. Default is 1.
|
|
*/
|
|
grafanaOrgId: string;
|
|
/**
|
|
* The filter for the Cluster variable in grafana dashboards.
|
|
*/
|
|
grafanaClusterFilter: string | undefined;
|
|
/**
|
|
* The uids of the dashboards that ray exports that powers the various metrics UIs.
|
|
*/
|
|
dashboardUids: DashboardUids | undefined;
|
|
/**
|
|
* Whether prometheus is runing or not
|
|
*/
|
|
prometheusHealth: boolean | undefined;
|
|
/**
|
|
* Whether platform events are enabled
|
|
*/
|
|
platformEventsEnabled?: boolean;
|
|
/**
|
|
* The name of the currently running ray session.
|
|
*/
|
|
sessionName: string | undefined;
|
|
/**
|
|
* The name of the current selected datasource.
|
|
*/
|
|
dashboardDatasource: string | undefined;
|
|
/**
|
|
* The timezone set on the ray cluster.
|
|
*/
|
|
serverTimeZone: TimezoneInfo | null | undefined;
|
|
/**
|
|
* The globally selected current time zone.
|
|
*/
|
|
currentTimeZone: string | undefined;
|
|
/**
|
|
* The current theme mode (light or dark)
|
|
*/
|
|
themeMode: "light" | "dark";
|
|
/**
|
|
* Function to toggle between light and dark mode
|
|
*/
|
|
toggleTheme: () => void;
|
|
};
|
|
export const GlobalContext = React.createContext<GlobalContextType>({
|
|
nodeMap: {},
|
|
nodeMapByIp: {},
|
|
namespaceMap: {},
|
|
metricsContextLoaded: false,
|
|
grafanaHost: undefined,
|
|
grafanaOrgId: "1",
|
|
grafanaClusterFilter: undefined,
|
|
dashboardUids: undefined,
|
|
prometheusHealth: undefined,
|
|
platformEventsEnabled: false,
|
|
sessionName: undefined,
|
|
dashboardDatasource: undefined,
|
|
serverTimeZone: undefined,
|
|
currentTimeZone: undefined,
|
|
themeMode: "light",
|
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
toggleTheme: () => {},
|
|
});
|
|
|
|
const App = () => {
|
|
const [currentTimeZone, setCurrentTimeZone] = useState<string>();
|
|
const [themeMode, setThemeMode] = useState<"light" | "dark">(() => {
|
|
// Check URL query param first (for embedded views)
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const urlTheme = urlParams.get("theme");
|
|
if (urlTheme === "dark" || urlTheme === "light") {
|
|
return urlTheme;
|
|
}
|
|
// Then check localStorage
|
|
const stored = localStorage.getItem("themeMode");
|
|
if (stored === "dark" || stored === "light") {
|
|
return stored;
|
|
}
|
|
// Fall back to system preference
|
|
if (
|
|
window.matchMedia &&
|
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
) {
|
|
return "dark";
|
|
}
|
|
return "light";
|
|
});
|
|
const [context, setContext] = useState<
|
|
Omit<GlobalContextType, "currentTimeZone" | "themeMode" | "toggleTheme">
|
|
>({
|
|
nodeMap: {},
|
|
nodeMapByIp: {},
|
|
namespaceMap: {},
|
|
metricsContextLoaded: false,
|
|
grafanaHost: undefined,
|
|
grafanaOrgId: "1",
|
|
grafanaClusterFilter: undefined,
|
|
dashboardUids: undefined,
|
|
prometheusHealth: undefined,
|
|
platformEventsEnabled: false,
|
|
sessionName: undefined,
|
|
dashboardDatasource: undefined,
|
|
serverTimeZone: undefined,
|
|
});
|
|
|
|
const toggleTheme = () => {
|
|
setThemeMode((prevMode) => {
|
|
const newMode = prevMode === "light" ? "dark" : "light";
|
|
localStorage.setItem("themeMode", newMode);
|
|
return newMode;
|
|
});
|
|
};
|
|
|
|
// Listen for system theme changes
|
|
useEffect(() => {
|
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
const handleChange = (e: MediaQueryListEvent) => {
|
|
// Only update if user hasn't explicitly set a preference via localStorage or URL
|
|
const stored = localStorage.getItem("themeMode");
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const urlTheme = urlParams.get("theme");
|
|
if (!stored && !urlTheme) {
|
|
setThemeMode(e.matches ? "dark" : "light");
|
|
}
|
|
};
|
|
|
|
mediaQuery.addEventListener("change", handleChange);
|
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
|
}, []);
|
|
|
|
// Authentication state
|
|
const [authenticationDialogOpen, setAuthenticationDialogOpen] =
|
|
useState(false);
|
|
const [hasAttemptedAuthentication, setHasAttemptedAuthentication] =
|
|
useState(false);
|
|
const [authenticationError, setAuthenticationError] =
|
|
useState<string | undefined>();
|
|
|
|
useEffect(() => {
|
|
getNodeList().then((res) => {
|
|
if (res?.data?.data?.summary) {
|
|
const nodeMap = {} as { [key: string]: string };
|
|
const nodeMapByIp = {} as { [key: string]: string };
|
|
res.data.data.summary.forEach(({ hostname, raylet, ip }) => {
|
|
nodeMap[hostname] = raylet.nodeId;
|
|
nodeMapByIp[ip] = raylet.nodeId;
|
|
});
|
|
setContext((existingContext) => ({
|
|
...existingContext,
|
|
nodeMap,
|
|
nodeMapByIp,
|
|
namespaceMap: {},
|
|
}));
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// Detect if grafana is running
|
|
useEffect(() => {
|
|
const doEffect = async () => {
|
|
const {
|
|
grafanaHost,
|
|
grafanaOrgId,
|
|
grafanaClusterFilter,
|
|
sessionName,
|
|
prometheusHealth,
|
|
dashboardUids,
|
|
dashboardDatasource,
|
|
} = await getMetricsInfo();
|
|
setContext((existingContext) => ({
|
|
...existingContext,
|
|
metricsContextLoaded: true,
|
|
grafanaHost,
|
|
grafanaOrgId,
|
|
grafanaClusterFilter,
|
|
dashboardUids,
|
|
sessionName,
|
|
prometheusHealth,
|
|
dashboardDatasource,
|
|
}));
|
|
};
|
|
doEffect();
|
|
}, []);
|
|
|
|
// Detect if platform events are enabled
|
|
useEffect(() => {
|
|
getPlatformEventsEnabled().then((platformEventsEnabled) => {
|
|
setContext((existingContext) => ({
|
|
...existingContext,
|
|
platformEventsEnabled,
|
|
}));
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const updateTimezone = async () => {
|
|
// Sets the initial timezone to localStorage value if it exists
|
|
const storedTimeZone = localStorage.getItem("timezone");
|
|
if (storedTimeZone) {
|
|
setCurrentTimeZone(storedTimeZone);
|
|
}
|
|
|
|
// Fetch the server time zone.
|
|
const tzInfo = await getTimeZoneInfo();
|
|
|
|
const timeZone =
|
|
storedTimeZone ||
|
|
tzInfo?.value ||
|
|
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
|
|
setCurrentTimeZone(timeZone);
|
|
setContext((existingContext) => ({
|
|
...existingContext,
|
|
serverTimeZone: tzInfo,
|
|
}));
|
|
};
|
|
updateTimezone();
|
|
}, []);
|
|
|
|
// Check authentication mode on mount and cache in sessionStorage
|
|
useEffect(() => {
|
|
const checkAuthentication = async () => {
|
|
try {
|
|
const { authentication_mode } = await getAuthenticationMode();
|
|
sessionStorage.setItem("ray-authentication-mode", authentication_mode);
|
|
} catch (error) {
|
|
console.error("Failed to check authentication mode:", error);
|
|
}
|
|
};
|
|
|
|
checkAuthentication();
|
|
}, []);
|
|
|
|
// Listen for authentication errors from axios interceptor
|
|
useEffect(() => {
|
|
const handleAuthenticationError = async (event: Event) => {
|
|
const customEvent = event as CustomEvent<{ hadToken: boolean }>;
|
|
const hadToken = customEvent.detail?.hadToken ?? false;
|
|
|
|
// Check cached auth mode first, fall back to API if not cached
|
|
let authMode = sessionStorage.getItem("ray-authentication-mode");
|
|
if (!authMode) {
|
|
try {
|
|
const { authentication_mode } = await getAuthenticationMode();
|
|
authMode = authentication_mode;
|
|
sessionStorage.setItem(
|
|
"ray-authentication-mode",
|
|
authentication_mode,
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to check authentication mode:", error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (authMode === "token" || authMode === "k8s") {
|
|
setHasAttemptedAuthentication(hadToken);
|
|
setAuthenticationDialogOpen(true);
|
|
}
|
|
};
|
|
|
|
window.addEventListener(
|
|
AUTHENTICATION_ERROR_EVENT,
|
|
handleAuthenticationError,
|
|
);
|
|
|
|
return () => {
|
|
window.removeEventListener(
|
|
AUTHENTICATION_ERROR_EVENT,
|
|
handleAuthenticationError,
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
// Handle token submission from dialog
|
|
const handleTokenSubmit = async (token: string) => {
|
|
try {
|
|
// Authenticate with the server - this will validate the token
|
|
// and set an HttpOnly cookie if valid
|
|
const isValid = await authenticateWithToken(token);
|
|
|
|
if (isValid) {
|
|
// Token is valid and server has set HttpOnly cookie
|
|
setHasAttemptedAuthentication(true);
|
|
setAuthenticationDialogOpen(false);
|
|
setAuthenticationError(undefined);
|
|
|
|
// Reload the page to refetch all data with the new token
|
|
window.location.reload();
|
|
} else {
|
|
// Token is invalid
|
|
setHasAttemptedAuthentication(true);
|
|
setAuthenticationError(
|
|
"Invalid authentication token. Please check and try again.",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to authenticate:", error);
|
|
setAuthenticationError(
|
|
"Failed to authenticate. Please check your connection and try again.",
|
|
);
|
|
}
|
|
};
|
|
|
|
const currentTheme = themeMode === "dark" ? darkTheme : lightTheme;
|
|
|
|
return (
|
|
<StyledEngineProvider injectFirst>
|
|
<ThemeProvider theme={currentTheme}>
|
|
<Suspense fallback={Loading}>
|
|
<GlobalContext.Provider
|
|
value={{
|
|
...context,
|
|
currentTimeZone,
|
|
themeMode,
|
|
toggleTheme,
|
|
}}
|
|
>
|
|
<CssBaseline />
|
|
<TokenAuthenticationDialog
|
|
open={authenticationDialogOpen}
|
|
hasExistingToken={hasAttemptedAuthentication}
|
|
onSubmit={handleTokenSubmit}
|
|
error={authenticationError}
|
|
/>
|
|
<HashRouter>
|
|
<Routes>
|
|
{/* Redirect people hitting the /new path to root. TODO(aguo): Delete this redirect in ray 2.5 */}
|
|
<Route element={<Navigate replace to="/" />} path="/new" />
|
|
<Route element={<MainNavLayout />} path="/">
|
|
<Route element={<Navigate replace to="overview" />} path="" />
|
|
<Route element={<OverviewPage />} path="overview" />
|
|
<Route
|
|
element={<PlatformEventsPage />}
|
|
path="platform-events"
|
|
/>
|
|
<Route element={<ClusterMainPageLayout />} path="cluster">
|
|
<Route element={<ClusterLayout />} path="">
|
|
<Route
|
|
element={
|
|
<SideTabPage tabId="info">
|
|
<ClusterDetailInfoPage />
|
|
</SideTabPage>
|
|
}
|
|
path="info"
|
|
/>
|
|
<Route
|
|
element={
|
|
<SideTabPage tabId="table">
|
|
<Nodes />
|
|
</SideTabPage>
|
|
}
|
|
path=""
|
|
/>
|
|
</Route>
|
|
<Route element={<NodeDetailPage />} path="nodes/:id" />
|
|
</Route>
|
|
<Route element={<JobsLayout />} path="jobs">
|
|
<Route element={<JobList />} path="" />
|
|
<Route element={<JobPage />} path=":id">
|
|
<Route element={<JobDetailLayout />} path="">
|
|
<Route
|
|
element={
|
|
<SideTabPage tabId="info">
|
|
<JobDetailInfoPage />
|
|
</SideTabPage>
|
|
}
|
|
path="info"
|
|
/>
|
|
<Route
|
|
element={
|
|
<SideTabPage tabId="charts">
|
|
<JobDetailChartsPage />
|
|
</SideTabPage>
|
|
}
|
|
path=""
|
|
/>
|
|
<Route
|
|
element={
|
|
<SideTabPage tabId="actors">
|
|
<JobDetailActorsPage />
|
|
</SideTabPage>
|
|
}
|
|
path="actors"
|
|
/>
|
|
</Route>
|
|
<Route
|
|
element={
|
|
<JobDetailActorDetailWrapper>
|
|
<ActorDetailLayout />
|
|
</JobDetailActorDetailWrapper>
|
|
}
|
|
path="actors/:actorId"
|
|
>
|
|
<Route element={<ActorDetailPage />} path="" />
|
|
<Route element={<TaskPage />} path="tasks/:taskId" />
|
|
</Route>
|
|
<Route element={<TaskPage />} path="tasks/:taskId" />
|
|
</Route>
|
|
</Route>
|
|
<Route element={<ActorLayout />} path="actors">
|
|
<Route element={<Actors />} path="" />
|
|
<Route element={<ActorDetailLayout />} path=":actorId">
|
|
<Route element={<ActorDetailPage />} path="" />
|
|
<Route element={<TaskPage />} path="tasks/:taskId" />
|
|
</Route>
|
|
</Route>
|
|
<Route element={<Metrics />} path="metrics" />
|
|
<Route element={<ServeLayout />} path="serve">
|
|
<Route element={<ServeSideTabLayout />} path="">
|
|
<Route
|
|
element={
|
|
<SideTabPage tabId="system">
|
|
<ServeSystemDetailPage />
|
|
</SideTabPage>
|
|
}
|
|
path="system"
|
|
/>
|
|
<Route
|
|
element={
|
|
<SideTabPage tabId="deployments">
|
|
<ServeDeploymentsListPage />
|
|
</SideTabPage>
|
|
}
|
|
path=""
|
|
/>
|
|
</Route>
|
|
<Route element={<ServeSystemDetailLayout />} path="system">
|
|
<Route
|
|
element={<ServeControllerDetailPage />}
|
|
path="controller"
|
|
/>
|
|
<Route
|
|
element={<ServeProxyDetailPage />}
|
|
path="proxies/:proxyId"
|
|
/>
|
|
</Route>
|
|
<Route
|
|
element={<ServeApplicationDetailLayout />}
|
|
path="applications/:applicationName"
|
|
>
|
|
<Route element={<ServeApplicationDetailPage />} path="" />
|
|
<Route
|
|
element={<ServeDeploymentDetailLayout />}
|
|
path=":deploymentName"
|
|
>
|
|
<Route
|
|
element={<ServeDeploymentDetailPage />}
|
|
path=""
|
|
/>
|
|
<Route
|
|
element={<ServeReplicaDetailLayout />}
|
|
path=":replicaId"
|
|
>
|
|
<Route element={<ServeReplicaDetailPage />} path="" />
|
|
<Route path="tasks/:taskId" element={<TaskPage />} />
|
|
</Route>
|
|
</Route>
|
|
</Route>
|
|
</Route>
|
|
<Route element={<LogsLayout />} path="logs">
|
|
<Route element={<StateApiLogsListPage />} path="" />
|
|
<Route element={<StateApiLogViewerPage />} path="viewer" />
|
|
</Route>
|
|
</Route>
|
|
<Route element={<CMDResult />} path="/cmd/:cmd/:ip/:pid" />
|
|
</Routes>
|
|
</HashRouter>
|
|
</GlobalContext.Provider>
|
|
</Suspense>
|
|
</ThemeProvider>
|
|
</StyledEngineProvider>
|
|
);
|
|
};
|
|
|
|
export default App;
|