diff --git a/App/FeatureSet/Dashboard/src/Components/Profiles/ProfileTable.tsx b/App/FeatureSet/Dashboard/src/Components/Profiles/ProfileTable.tsx index 770bc2abb3..568701a67a 100644 --- a/App/FeatureSet/Dashboard/src/Components/Profiles/ProfileTable.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Profiles/ProfileTable.tsx @@ -211,6 +211,9 @@ const ProfileTable: FunctionComponent = ( } } query={query} + selectMoreFields={{ + profileId: true, + }} showViewIdButton={true} noItemsMessage={ props.noItemsMessage diff --git a/App/FeatureSet/Dashboard/src/Components/Profiles/ProfilesDashboard.tsx b/App/FeatureSet/Dashboard/src/Components/Profiles/ProfilesDashboard.tsx new file mode 100644 index 0000000000..88aea5bca5 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Profiles/ProfilesDashboard.tsx @@ -0,0 +1,428 @@ +import React, { + Fragment, + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; +import Service from "Common/Models/DatabaseModels/Service"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import ProjectUtil from "Common/UI/Utils/Project"; +import API from "Common/Utils/API"; +import { APP_API_URL } from "Common/UI/Config"; +import URL from "Common/Types/API/URL"; +import HTTPResponse from "Common/Types/API/HTTPResponse"; +import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse"; +import { JSONObject } from "Common/Types/JSON"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; +import SortOrder from "Common/Types/BaseDatabase/SortOrder"; +import ListResult from "Common/Types/BaseDatabase/ListResult"; +import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax"; +import Profile from "Common/Models/AnalyticsModels/Profile"; +import AnalyticsModelAPI, { + ListResult as AnalyticsListResult, +} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI"; +import InBetween from "Common/Types/BaseDatabase/InBetween"; +import OneUptimeDate from "Common/Types/Date"; +import ObjectID from "Common/Types/ObjectID"; +import ServiceElement from "../Service/ServiceElement"; +import ProfileUtil from "../../Utils/ProfileUtil"; +import RouteMap, { RouteUtil } from "../../Utils/RouteMap"; +import PageMap from "../../Utils/PageMap"; +import Route from "Common/Types/API/Route"; +import AppLink from "../AppLink/AppLink"; + +interface ServiceProfileSummary { + service: Service; + profileCount: number; + latestProfileTime: Date | null; + profileTypes: Array; +} + +interface FunctionHotspot { + functionName: string; + fileName: string; + selfValue: number; + totalValue: number; + sampleCount: number; + frameType: string; +} + +const ProfilesDashboard: FunctionComponent = (): ReactElement => { + const [serviceSummaries, setServiceSummaries] = useState< + Array + >([]); + const [hotspots, setHotspots] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const loadDashboard: () => Promise = async (): Promise => { + try { + setIsLoading(true); + setError(""); + + const now: Date = OneUptimeDate.getCurrentDate(); + const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1); + + // Load services + const servicesResult: ListResult = await ModelAPI.getList({ + modelType: Service, + query: { + projectId: ProjectUtil.getCurrentProjectId()!, + }, + select: { + serviceColor: true, + name: true, + }, + limit: LIMIT_PER_PROJECT, + skip: 0, + sort: { + name: SortOrder.Ascending, + }, + }); + + const services: Array = servicesResult.data || []; + + // Load recent profiles (last 1 hour) to build per-service summaries + const profilesResult: AnalyticsListResult = + await AnalyticsModelAPI.getList({ + modelType: Profile, + query: { + projectId: ProjectUtil.getCurrentProjectId()!, + startTime: new InBetween(oneHourAgo, now), + }, + select: { + serviceId: true, + profileType: true, + startTime: true, + sampleCount: true, + }, + limit: 5000, + skip: 0, + sort: { + startTime: SortOrder.Descending, + }, + }); + + const profiles: Array = profilesResult.data || []; + + // Build per-service summaries + const summaryMap: Map = new Map(); + + for (const service of services) { + const serviceId: string = service.id?.toString() || ""; + summaryMap.set(serviceId, { + service, + profileCount: 0, + latestProfileTime: null, + profileTypes: [], + }); + } + + for (const profile of profiles) { + const serviceId: string = profile.serviceId?.toString() || ""; + let summary: ServiceProfileSummary | undefined = + summaryMap.get(serviceId); + + if (!summary) { + continue; + } + + summary.profileCount += 1; + + const profileTime: Date | undefined = profile.startTime + ? new Date(profile.startTime) + : undefined; + + if ( + profileTime && + (!summary.latestProfileTime || + profileTime > summary.latestProfileTime) + ) { + summary.latestProfileTime = profileTime; + } + + const profileType: string = profile.profileType || ""; + + if (profileType && !summary.profileTypes.includes(profileType)) { + summary.profileTypes.push(profileType); + } + } + + // Only show services that have profiles + const summariesWithData: Array = Array.from( + summaryMap.values(), + ).filter((s: ServiceProfileSummary) => { + return s.profileCount > 0; + }); + + // Sort by profile count descending + summariesWithData.sort( + (a: ServiceProfileSummary, b: ServiceProfileSummary) => { + return b.profileCount - a.profileCount; + }, + ); + + setServiceSummaries(summariesWithData); + + // Load top hotspots (function list) across all services + try { + const hotspotsResponse: HTTPResponse | HTTPErrorResponse = + await API.post({ + url: URL.fromString(APP_API_URL.toString()).addRoute( + "/telemetry/profiles/function-list", + ), + data: { + startTime: oneHourAgo.toISOString(), + endTime: now.toISOString(), + limit: 10, + sortBy: "selfValue", + }, + headers: { + ...ModelAPI.getCommonHeaders(), + }, + }); + + if (hotspotsResponse instanceof HTTPErrorResponse) { + throw hotspotsResponse; + } + + const functions: Array = (hotspotsResponse.data[ + "functions" + ] || []) as unknown as Array; + setHotspots(functions); + } catch (_hotspotsErr) { + // Hotspots are optional - don't fail the whole page + setHotspots([]); + } + } catch (err) { + setError(API.getFriendlyErrorMessage(err as Error)); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void loadDashboard(); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ( + { + void loadDashboard(); + }} + /> + ); + } + + if (serviceSummaries.length === 0) { + return ( +
+
+ + + +
+

+ No performance data yet +

+

+ Once your services start sending profiling data, you{"'"}ll see a + summary of which services are being profiled, their performance + hotspots, and more. +

+
+ ); + } + + return ( + + {/* Service Cards */} +
+
+
+

+ Services Being Profiled +

+

+ Performance data collected in the last hour +

+
+ + View all profiles + +
+
+ {serviceSummaries.map((summary: ServiceProfileSummary) => { + return ( +
+
+ + + Active + +
+ +
+
+

Profiles

+

+ {summary.profileCount} +

+
+
+

Last Captured

+

+ {summary.latestProfileTime + ? OneUptimeDate.getDateAsLocalFormattedString( + summary.latestProfileTime, + true, + ) + : "-"} +

+
+
+ +
+

+ Profile Types Collected +

+
+ {summary.profileTypes.map((profileType: string) => { + const badgeColor: string = + ProfileUtil.getProfileTypeBadgeColor(profileType); + return ( + + {ProfileUtil.getProfileTypeDisplayName(profileType)} + + ); + })} +
+
+ +
+ + View service profiles + +
+
+ ); + })} +
+
+ + {/* Top Hotspots */} + {hotspots.length > 0 && ( +
+
+

+ Top Performance Hotspots +

+

+ Functions using the most resources across all services in the last + hour +

+
+
+ + + + + + + + + + + + + {hotspots.map((fn: FunctionHotspot, index: number) => { + const maxSelf: number = hotspots[0]?.selfValue || 1; + const barWidth: number = (fn.selfValue / maxSelf) * 100; + + return ( + + + + + + + + + ); + })} + +
#FunctionSource File + Own Time + + Total Time + + Occurrences +
+ {index + 1} + +
+ {fn.functionName} +
+
+
+ {fn.fileName || "-"} + + {fn.selfValue.toLocaleString()} + + {fn.totalValue.toLocaleString()} + + {fn.sampleCount.toLocaleString()} +
+
+
+ )} +
+ ); +}; + +export default ProfilesDashboard; diff --git a/App/FeatureSet/Dashboard/src/Components/Telemetry/Documentation.tsx b/App/FeatureSet/Dashboard/src/Components/Telemetry/Documentation.tsx index 63bca8eb89..48293f7255 100644 --- a/App/FeatureSet/Dashboard/src/Components/Telemetry/Documentation.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Telemetry/Documentation.tsx @@ -1728,7 +1728,7 @@ const TelemetryDocumentation: FunctionComponent = ( tokenValue, pyroscopeUrl, )} - language="hcl" + language="nginx" />, )} diff --git a/App/FeatureSet/Dashboard/src/Pages/Profiles/Index.tsx b/App/FeatureSet/Dashboard/src/Pages/Profiles/Index.tsx index 51a5c9a70d..fded3119d2 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Profiles/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Profiles/Index.tsx @@ -12,7 +12,7 @@ import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; import API from "Common/UI/Utils/API/API"; import PageLoader from "Common/UI/Components/Loader/PageLoader"; import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; -import ProfileTable from "../../Components/Profiles/ProfileTable"; +import ProfilesDashboard from "../../Components/Profiles/ProfilesDashboard"; const ProfilesPage: FunctionComponent = ( props: PageComponentProps, @@ -62,7 +62,7 @@ const ProfilesPage: FunctionComponent = ( return ; } - return ; + return ; }; export default ProfilesPage; diff --git a/App/FeatureSet/Dashboard/src/Pages/Profiles/List.tsx b/App/FeatureSet/Dashboard/src/Pages/Profiles/List.tsx new file mode 100644 index 0000000000..ad1277d0ce --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Profiles/List.tsx @@ -0,0 +1,11 @@ +import PageComponentProps from "../PageComponentProps"; +import React, { FunctionComponent, ReactElement } from "react"; +import ProfileTable from "../../Components/Profiles/ProfileTable"; + +const ProfilesListPage: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + return ; +}; + +export default ProfilesListPage; diff --git a/App/FeatureSet/Dashboard/src/Pages/Profiles/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Profiles/SideMenu.tsx index c73cf15313..fbed4934fb 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Profiles/SideMenu.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Profiles/SideMenu.tsx @@ -14,11 +14,20 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => { items: [ { link: { - title: "All Profiles", + title: "Overview", to: RouteUtil.populateRouteParams( RouteMap[PageMap.PROFILES] as Route, ), }, + icon: IconProp.Home, + }, + { + link: { + title: "All Profiles", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.PROFILES_LIST] as Route, + ), + }, icon: IconProp.Fire, }, ], diff --git a/App/FeatureSet/Dashboard/src/Routes/ProfilesRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/ProfilesRoutes.tsx index 3a6af7a4fc..e8183fa3b8 100644 --- a/App/FeatureSet/Dashboard/src/Routes/ProfilesRoutes.tsx +++ b/App/FeatureSet/Dashboard/src/Routes/ProfilesRoutes.tsx @@ -9,6 +9,7 @@ import { Route as PageRoute, Routes } from "react-router-dom"; // Pages import ProfilesPage from "../Pages/Profiles/Index"; +import ProfilesListPage from "../Pages/Profiles/List"; import ProfilesDocumentationPage from "../Pages/Profiles/Documentation"; import ProfileViewPage from "../Pages/Profiles/View/Index"; @@ -27,6 +28,15 @@ const ProfilesRoutes: FunctionComponent = ( /> } /> + + } + /> | undefined { "Project", "Performance Profiles", ]), + ...BuildBreadcrumbLinksByTitles(PageMap.PROFILES_LIST, [ + "Project", + "Performance Profiles", + "All Profiles", + ]), ...BuildBreadcrumbLinksByTitles(PageMap.PROFILE_VIEW, [ "Project", "Performance Profiles", diff --git a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts index 03b7deaf9d..c60825812d 100644 --- a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts @@ -27,6 +27,7 @@ enum PageMap { // Profiles (standalone product) PROFILES_ROOT = "PROFILES_ROOT", PROFILES = "PROFILES", + PROFILES_LIST = "PROFILES_LIST", PROFILE_VIEW = "PROFILE_VIEW", PROFILES_DOCUMENTATION = "PROFILES_DOCUMENTATION", diff --git a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts index cf160ce0a1..31c0944804 100644 --- a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts @@ -137,6 +137,7 @@ export const TracesRoutePath: Dictionary = { // Profiles product routes export const ProfilesRoutePath: Dictionary = { [PageMap.PROFILES]: "", + [PageMap.PROFILES_LIST]: "list", [PageMap.PROFILE_VIEW]: `view/${RouteParams.ModelID}`, [PageMap.PROFILES_DOCUMENTATION]: "documentation", }; @@ -2303,6 +2304,12 @@ const RouteMap: Dictionary = { [PageMap.PROFILES]: new Route(`/dashboard/${RouteParams.ProjectID}/profiles`), + [PageMap.PROFILES_LIST]: new Route( + `/dashboard/${RouteParams.ProjectID}/profiles/${ + ProfilesRoutePath[PageMap.PROFILES_LIST] + }`, + ), + [PageMap.PROFILE_VIEW]: new Route( `/dashboard/${RouteParams.ProjectID}/profiles/${ ProfilesRoutePath[PageMap.PROFILE_VIEW]