mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
Enhance Traces Dashboard with latency percentiles and service health metrics
- Added global P50, P95, and P99 latency calculations to the TracesDashboard component. - Introduced a new formatDuration function to format latency values for display. - Updated service summaries to include per-service P50 and P95 latencies. - Improved the layout of the dashboard to display overall requests, error rates, and latency metrics. - Refactored service health indicators to visually represent error rates with color coding. - Adjusted the display of recent errors and slow requests for better user experience. - Updated E2E tests for admin and public dashboards to include response type checks.
This commit is contained in:
@@ -16,6 +16,7 @@ import SortOrder from "Common/Types/BaseDatabase/SortOrder";
|
||||
import ListResult from "Common/Types/BaseDatabase/ListResult";
|
||||
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import TelemetryServiceElement from "../TelemetryService/TelemetryServiceElement";
|
||||
import TelemetryExceptionElement from "./ExceptionElement";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
@@ -36,6 +37,9 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [topExceptions, setTopExceptions] = useState<Array<TelemetryException>>(
|
||||
[],
|
||||
);
|
||||
const [recentExceptions, setRecentExceptions] = useState<
|
||||
Array<TelemetryException>
|
||||
>([]);
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceExceptionSummary>
|
||||
>([]);
|
||||
@@ -49,12 +53,12 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
|
||||
const projectId: ObjectID = ProjectUtil.getCurrentProjectId()!;
|
||||
|
||||
// Load counts, top exceptions, and services in parallel
|
||||
const [
|
||||
unresolvedResult,
|
||||
resolvedResult,
|
||||
archivedResult,
|
||||
topExceptionsResult,
|
||||
recentExceptionsResult,
|
||||
servicesResult,
|
||||
] = await Promise.all([
|
||||
ModelAPI.count({
|
||||
@@ -108,6 +112,34 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
occuranceCount: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: TelemetryException,
|
||||
query: {
|
||||
projectId,
|
||||
isResolved: false,
|
||||
isArchived: false,
|
||||
},
|
||||
select: {
|
||||
message: true,
|
||||
exceptionType: true,
|
||||
fingerprint: true,
|
||||
isResolved: true,
|
||||
isArchived: true,
|
||||
occuranceCount: true,
|
||||
lastSeenAt: true,
|
||||
firstSeenAt: true,
|
||||
environment: true,
|
||||
service: {
|
||||
name: true,
|
||||
serviceColor: true,
|
||||
} as any,
|
||||
},
|
||||
limit: 8,
|
||||
skip: 0,
|
||||
sort: {
|
||||
lastSeenAt: SortOrder.Descending,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
@@ -129,6 +161,7 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
setResolvedCount(resolvedResult);
|
||||
setArchivedCount(archivedResult);
|
||||
setTopExceptions(topExceptionsResult.data || []);
|
||||
setRecentExceptions(recentExceptionsResult.data || []);
|
||||
|
||||
const loadedServices: Array<Service> = servicesResult.data || [];
|
||||
|
||||
@@ -136,7 +169,6 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
const serviceExceptionCounts: Array<ServiceExceptionSummary> = [];
|
||||
|
||||
for (const service of loadedServices) {
|
||||
// Get unresolved exceptions for this service
|
||||
const serviceExceptions: ListResult<TelemetryException> =
|
||||
await ModelAPI.getList({
|
||||
modelType: TelemetryException,
|
||||
@@ -161,11 +193,9 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
|
||||
if (exceptions.length > 0) {
|
||||
let totalOccurrences: number = 0;
|
||||
|
||||
for (const ex of exceptions) {
|
||||
totalOccurrences += ex.occuranceCount || 0;
|
||||
}
|
||||
|
||||
serviceExceptionCounts.push({
|
||||
service,
|
||||
unresolvedCount: exceptions.length,
|
||||
@@ -174,11 +204,9 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by unresolved count descending
|
||||
serviceExceptionCounts.sort(
|
||||
(a: ServiceExceptionSummary, b: ServiceExceptionSummary) => {
|
||||
return b.unresolvedCount - a.unresolvedCount;
|
||||
},
|
||||
(a: ServiceExceptionSummary, b: ServiceExceptionSummary) =>
|
||||
b.unresolvedCount - a.unresolvedCount,
|
||||
);
|
||||
|
||||
setServiceSummaries(serviceExceptionCounts);
|
||||
@@ -212,69 +240,145 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
|
||||
if (totalCount === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-12 text-center">
|
||||
<div className="text-gray-400 text-5xl mb-4">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-green-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12"
|
||||
className="h-8 w-8 text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25 0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 12s-.148-.277-.277-.5M19.08 12s.147-.277.277-.5"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No exceptions caught yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-md mx-auto">
|
||||
Once your services start reporting exceptions, you{"'"}ll see a
|
||||
summary of bugs, their frequency, and which services are most
|
||||
affected.
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start reporting exceptions, you{"'"}ll see bug
|
||||
frequency, affected services, and resolution status here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resolutionRate: number =
|
||||
totalCount > 0
|
||||
? Math.round(((resolvedCount + archivedCount) / totalCount) * 100)
|
||||
: 0;
|
||||
|
||||
// Count how many of the top exceptions were first seen in last 24h
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneDayAgo: Date = OneUptimeDate.addRemoveHours(now, -24);
|
||||
const newTodayCount: number = topExceptions.filter(
|
||||
(e: TelemetryException) => {
|
||||
return e.firstSeenAt && new Date(e.firstSeenAt) > oneDayAgo;
|
||||
},
|
||||
).length;
|
||||
|
||||
const maxServiceBugs: number =
|
||||
serviceSummaries.length > 0 ? serviceSummaries[0]!.unresolvedCount : 1;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Unresolved Alert Banner */}
|
||||
{unresolvedCount > 0 && (
|
||||
<AppLink
|
||||
className="block mb-6"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_UNRESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`rounded-xl p-4 flex items-center justify-between ${unresolvedCount > 20 ? "bg-red-50 border border-red-200" : unresolvedCount > 5 ? "bg-amber-50 border border-amber-200" : "bg-blue-50 border border-blue-200"}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-lg flex items-center justify-center ${unresolvedCount > 20 ? "bg-red-100" : unresolvedCount > 5 ? "bg-amber-100" : "bg-blue-100"}`}
|
||||
>
|
||||
<svg
|
||||
className={`h-5 w-5 ${unresolvedCount > 20 ? "text-red-600" : unresolvedCount > 5 ? "text-amber-600" : "text-blue-600"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146 1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0 5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0 002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12 8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734 3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25 0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62 1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 12s-.148-.277-.277-.5M19.08 12s.147-.277.277-.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className={`text-sm font-semibold ${unresolvedCount > 20 ? "text-red-800" : unresolvedCount > 5 ? "text-amber-800" : "text-blue-800"}`}
|
||||
>
|
||||
{unresolvedCount} unresolved{" "}
|
||||
{unresolvedCount === 1 ? "bug" : "bugs"} need attention
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs mt-0.5 ${unresolvedCount > 20 ? "text-red-600" : unresolvedCount > 5 ? "text-amber-600" : "text-blue-600"}`}
|
||||
>
|
||||
{newTodayCount > 0
|
||||
? `${newTodayCount} new in the last 24 hours`
|
||||
: "Click to view and triage"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`h-5 w-5 ${unresolvedCount > 20 ? "text-red-400" : unresolvedCount > 5 ? "text-amber-400" : "text-blue-400"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</AppLink>
|
||||
)}
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<AppLink
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.EXCEPTIONS_UNRESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-red-200 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Unresolved Bugs</p>
|
||||
<p className="text-3xl font-bold text-red-600 mt-1">
|
||||
{unresolvedCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-full bg-red-50 flex items-center justify-center">
|
||||
<p className="text-sm font-medium text-gray-500">Unresolved</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-red-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-500"
|
||||
className="h-4 w-4 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">Needs attention</p>
|
||||
<p className="text-3xl font-bold text-red-600 mt-2">
|
||||
{unresolvedCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">needs attention</p>
|
||||
</div>
|
||||
</AppLink>
|
||||
|
||||
@@ -284,31 +388,29 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
RouteMap[PageMap.EXCEPTIONS_RESOLVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-green-200 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Resolved</p>
|
||||
<p className="text-3xl font-bold text-green-600 mt-1">
|
||||
{resolvedCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-full bg-green-50 flex items-center justify-center">
|
||||
<p className="text-sm font-medium text-gray-500">Resolved</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-green-500"
|
||||
className="h-4 w-4 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">Fixed and verified</p>
|
||||
<p className="text-3xl font-bold text-green-600 mt-2">
|
||||
{resolvedCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">fixed</p>
|
||||
</div>
|
||||
</AppLink>
|
||||
|
||||
@@ -318,49 +420,73 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
RouteMap[PageMap.EXCEPTIONS_ARCHIVED] as Route,
|
||||
)}
|
||||
>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-gray-300 hover:shadow-sm transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Archived</p>
|
||||
<p className="text-3xl font-bold text-gray-600 mt-1">
|
||||
{archivedCount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-full bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-sm font-medium text-gray-500">Archived</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-gray-100 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-6 w-6 text-gray-400"
|
||||
className="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
|
||||
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Dismissed or won{"'"}t fix
|
||||
<p className="text-3xl font-bold text-gray-600 mt-2">
|
||||
{archivedCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">dismissed</p>
|
||||
</div>
|
||||
</AppLink>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Resolution Rate</p>
|
||||
<div className="h-8 w-8 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4 w-4 text-indigo-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{resolutionRate}%
|
||||
</p>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden mt-2">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-400"
|
||||
style={{ width: `${resolutionRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Most Frequent Exceptions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Most Frequent Exceptions - takes 2 columns */}
|
||||
{topExceptions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Most Frequent Bugs
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Unresolved exceptions with the highest occurrence count
|
||||
</p>
|
||||
</div>
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
@@ -371,8 +497,8 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
View all
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{topExceptions.map(
|
||||
(exception: TelemetryException, index: number) => {
|
||||
const maxOccurrences: number =
|
||||
@@ -380,6 +506,11 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
const barWidth: number =
|
||||
((exception.occuranceCount || 0) / maxOccurrences) * 100;
|
||||
|
||||
const isNewToday: boolean = !!(
|
||||
exception.firstSeenAt &&
|
||||
new Date(exception.firstSeenAt) > oneDayAgo
|
||||
);
|
||||
|
||||
return (
|
||||
<AppLink
|
||||
key={exception.id?.toString() || index.toString()}
|
||||
@@ -393,7 +524,10 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
] as Route,
|
||||
)
|
||||
.toString()
|
||||
.replace(/\/$/, `/${exception.fingerprint}`),
|
||||
.replace(
|
||||
/\/?$/,
|
||||
`/${exception.fingerprint}`,
|
||||
),
|
||||
)
|
||||
: RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
@@ -402,39 +536,60 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1">
|
||||
<div className="flex items-start justify-between mb-1.5">
|
||||
<div className="min-w-0 flex-1 mr-3">
|
||||
<TelemetryExceptionElement
|
||||
message={
|
||||
exception.message ||
|
||||
exception.exceptionType ||
|
||||
"Unknown exception"
|
||||
}
|
||||
isResolved={exception.isResolved || false}
|
||||
isArchived={exception.isArchived || false}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TelemetryExceptionElement
|
||||
message={
|
||||
exception.message ||
|
||||
exception.exceptionType ||
|
||||
"Unknown exception"
|
||||
}
|
||||
isResolved={exception.isResolved || false}
|
||||
isArchived={exception.isArchived || false}
|
||||
className="text-sm"
|
||||
/>
|
||||
{isNewToday && (
|
||||
<span className="flex-shrink-0 text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded font-medium">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{exception.service && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{exception.service.name?.toString()}
|
||||
</span>
|
||||
)}
|
||||
{exception.exceptionType && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded font-mono">
|
||||
{exception.exceptionType}
|
||||
</span>
|
||||
)}
|
||||
{exception.environment && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">
|
||||
{exception.environment}
|
||||
</span>
|
||||
)}
|
||||
{exception.lastSeenAt && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{OneUptimeDate.fromNow(
|
||||
new Date(exception.lastSeenAt),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{(exception.occuranceCount || 0).toLocaleString()}
|
||||
<p className="text-sm font-bold text-gray-900">
|
||||
{(
|
||||
exception.occuranceCount || 0
|
||||
).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">occurrences</p>
|
||||
<p className="text-xs text-gray-400">hits</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<div className="mt-1.5">
|
||||
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-400"
|
||||
@@ -451,51 +606,136 @@ const ExceptionsDashboard: FunctionComponent = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Affected */}
|
||||
{serviceSummaries.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Affected Services
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Services with unresolved exceptions
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-100">
|
||||
{serviceSummaries.map((summary: ServiceExceptionSummary) => {
|
||||
return (
|
||||
<div
|
||||
key={summary.service.id?.toString()}
|
||||
className="px-4 py-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<TelemetryServiceElement
|
||||
telemetryService={summary.service}
|
||||
/>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-red-600">
|
||||
{summary.unresolvedCount}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">unresolved</p>
|
||||
{/* Right sidebar: Affected Services + Recently Seen */}
|
||||
<div className="space-y-6">
|
||||
{/* Affected Services */}
|
||||
{serviceSummaries.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Affected Services
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{serviceSummaries.map(
|
||||
(summary: ServiceExceptionSummary) => {
|
||||
const barWidth: number =
|
||||
(summary.unresolvedCount / maxServiceBugs) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={summary.service.id?.toString()}
|
||||
className="px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<TelemetryServiceElement
|
||||
telemetryService={summary.service}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-bold text-red-600">
|
||||
{summary.unresolvedCount}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
bugs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-gray-700">
|
||||
{summary.totalOccurrences.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">total hits</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-red-400"
|
||||
style={{
|
||||
width: `${Math.max(barWidth, 3)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{summary.totalOccurrences.toLocaleString()} hits
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Recently Active */}
|
||||
{recentExceptions.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Recently Active
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentExceptions
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(exception: TelemetryException, index: number) => {
|
||||
return (
|
||||
<AppLink
|
||||
key={
|
||||
exception.id?.toString() || index.toString()
|
||||
}
|
||||
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
to={
|
||||
exception.fingerprint
|
||||
? new Route(
|
||||
RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_VIEW_ROOT
|
||||
] as Route,
|
||||
)
|
||||
.toString()
|
||||
.replace(
|
||||
/\/?$/,
|
||||
`/${exception.fingerprint}`,
|
||||
),
|
||||
)
|
||||
: RouteUtil.populateRouteParams(
|
||||
RouteMap[
|
||||
PageMap.EXCEPTIONS_UNRESOLVED
|
||||
] as Route,
|
||||
)
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-900 truncate font-medium">
|
||||
{exception.message ||
|
||||
exception.exceptionType ||
|
||||
"Unknown"}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{exception.service && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{exception.service.name?.toString()}
|
||||
</span>
|
||||
)}
|
||||
{exception.lastSeenAt && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{OneUptimeDate.fromNow(
|
||||
new Date(exception.lastSeenAt),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ import API from "Common/UI/Utils/API/API";
|
||||
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 ObjectID from "Common/Types/ObjectID";
|
||||
import ServiceElement from "../Service/ServiceElement";
|
||||
@@ -26,6 +25,17 @@ interface ServiceMetricSummary {
|
||||
service: Service;
|
||||
metricCount: number;
|
||||
metricNames: Array<string>;
|
||||
metricUnits: Array<string>;
|
||||
metricDescriptions: Array<string>;
|
||||
hasSystemMetrics: boolean;
|
||||
hasAppMetrics: boolean;
|
||||
}
|
||||
|
||||
interface MetricCategory {
|
||||
name: string;
|
||||
count: number;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
const MetricsDashboard: FunctionComponent = (): ReactElement => {
|
||||
@@ -33,59 +43,143 @@ const MetricsDashboard: FunctionComponent = (): ReactElement => {
|
||||
Array<ServiceMetricSummary>
|
||||
>([]);
|
||||
const [totalMetricCount, setTotalMetricCount] = useState<number>(0);
|
||||
const [metricCategories, setMetricCategories] = useState<
|
||||
Array<MetricCategory>
|
||||
>([]);
|
||||
const [servicesWithNoMetrics, setServicesWithNoMetrics] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const categorizeMetric: (name: string) => string = (
|
||||
name: string,
|
||||
): string => {
|
||||
const lower: string = name.toLowerCase();
|
||||
if (
|
||||
lower.includes("cpu") ||
|
||||
lower.includes("memory") ||
|
||||
lower.includes("disk") ||
|
||||
lower.includes("network") ||
|
||||
lower.includes("system") ||
|
||||
lower.includes("process") ||
|
||||
lower.includes("runtime") ||
|
||||
lower.includes("gc")
|
||||
) {
|
||||
return "System";
|
||||
}
|
||||
if (
|
||||
lower.includes("http") ||
|
||||
lower.includes("request") ||
|
||||
lower.includes("response") ||
|
||||
lower.includes("latency") ||
|
||||
lower.includes("duration") ||
|
||||
lower.includes("rpc")
|
||||
) {
|
||||
return "Request";
|
||||
}
|
||||
if (
|
||||
lower.includes("db") ||
|
||||
lower.includes("database") ||
|
||||
lower.includes("query") ||
|
||||
lower.includes("connection") ||
|
||||
lower.includes("pool")
|
||||
) {
|
||||
return "Database";
|
||||
}
|
||||
if (
|
||||
lower.includes("queue") ||
|
||||
lower.includes("message") ||
|
||||
lower.includes("kafka") ||
|
||||
lower.includes("rabbit") ||
|
||||
lower.includes("publish") ||
|
||||
lower.includes("consume")
|
||||
) {
|
||||
return "Messaging";
|
||||
}
|
||||
return "Custom";
|
||||
};
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
// Load services
|
||||
const servicesResult: ListResult<Service> = await ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
// Load services and metrics in parallel
|
||||
const [servicesResult, metricsResult] = await Promise.all([
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
ModelAPI.getList({
|
||||
modelType: MetricType,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
unit: true,
|
||||
description: true,
|
||||
services: {
|
||||
_id: true,
|
||||
name: true,
|
||||
serviceColor: true,
|
||||
} as any,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const services: Array<Service> = servicesResult.data || [];
|
||||
|
||||
// Load all metric types with their services
|
||||
const metricsResult: ListResult<MetricType> = await ModelAPI.getList({
|
||||
modelType: MetricType,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
unit: true,
|
||||
description: true,
|
||||
services: {
|
||||
_id: true,
|
||||
name: true,
|
||||
serviceColor: true,
|
||||
} as any,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
});
|
||||
|
||||
const metrics: Array<MetricType> = metricsResult.data || [];
|
||||
setTotalMetricCount(metrics.length);
|
||||
|
||||
// Build category counts
|
||||
const categoryMap: Map<string, number> = new Map();
|
||||
for (const metric of metrics) {
|
||||
const cat: string = categorizeMetric(metric.name || "");
|
||||
categoryMap.set(cat, (categoryMap.get(cat) || 0) + 1);
|
||||
}
|
||||
|
||||
const categoryColors: Record<string, { color: string; bgColor: string }> =
|
||||
{
|
||||
System: { color: "text-blue-700", bgColor: "bg-blue-50" },
|
||||
Request: { color: "text-purple-700", bgColor: "bg-purple-50" },
|
||||
Database: { color: "text-amber-700", bgColor: "bg-amber-50" },
|
||||
Messaging: { color: "text-green-700", bgColor: "bg-green-50" },
|
||||
Custom: { color: "text-gray-700", bgColor: "bg-gray-50" },
|
||||
};
|
||||
|
||||
const categories: Array<MetricCategory> = Array.from(
|
||||
categoryMap.entries(),
|
||||
)
|
||||
.map(([name, count]: [string, number]) => {
|
||||
return {
|
||||
name,
|
||||
count,
|
||||
color: categoryColors[name]?.color || "text-gray-700",
|
||||
bgColor: categoryColors[name]?.bgColor || "bg-gray-50",
|
||||
};
|
||||
})
|
||||
.sort((a: MetricCategory, b: MetricCategory) => {
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
setMetricCategories(categories);
|
||||
|
||||
// Build per-service summaries
|
||||
const summaryMap: Map<string, ServiceMetricSummary> = new Map();
|
||||
|
||||
@@ -95,11 +189,16 @@ const MetricsDashboard: FunctionComponent = (): ReactElement => {
|
||||
service,
|
||||
metricCount: 0,
|
||||
metricNames: [],
|
||||
metricUnits: [],
|
||||
metricDescriptions: [],
|
||||
hasSystemMetrics: false,
|
||||
hasAppMetrics: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const metric of metrics) {
|
||||
const metricServices: Array<Service> = metric.services || [];
|
||||
const cat: string = categorizeMetric(metric.name || "");
|
||||
|
||||
for (const metricService of metricServices) {
|
||||
const serviceId: string =
|
||||
@@ -108,31 +207,42 @@ const MetricsDashboard: FunctionComponent = (): ReactElement => {
|
||||
summaryMap.get(serviceId);
|
||||
|
||||
if (!summary) {
|
||||
// Service exists in metric but wasn't in our services list
|
||||
summary = {
|
||||
service: metricService,
|
||||
metricCount: 0,
|
||||
metricNames: [],
|
||||
metricUnits: [],
|
||||
metricDescriptions: [],
|
||||
hasSystemMetrics: false,
|
||||
hasAppMetrics: false,
|
||||
};
|
||||
summaryMap.set(serviceId, summary);
|
||||
}
|
||||
|
||||
summary.metricCount += 1;
|
||||
|
||||
if (cat === "System") {
|
||||
summary.hasSystemMetrics = true;
|
||||
} else {
|
||||
summary.hasAppMetrics = true;
|
||||
}
|
||||
|
||||
const metricName: string = metric.name || "";
|
||||
if (metricName && summary.metricNames.length < 5) {
|
||||
if (metricName && summary.metricNames.length < 6) {
|
||||
summary.metricNames.push(metricName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only show services that have metrics
|
||||
const summariesWithData: Array<ServiceMetricSummary> = Array.from(
|
||||
summaryMap.values(),
|
||||
).filter((s: ServiceMetricSummary) => {
|
||||
return s.metricCount > 0;
|
||||
});
|
||||
|
||||
const noMetricsCount: number = services.length - summariesWithData.length;
|
||||
setServicesWithNoMetrics(noMetricsCount);
|
||||
|
||||
// Sort by metric count descending
|
||||
summariesWithData.sort(
|
||||
(a: ServiceMetricSummary, b: ServiceMetricSummary) => {
|
||||
@@ -169,102 +279,225 @@ const MetricsDashboard: FunctionComponent = (): ReactElement => {
|
||||
|
||||
if (serviceSummaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-12 text-center">
|
||||
<div className="text-gray-400 text-5xl mb-4">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-indigo-200"
|
||||
className="h-8 w-8 text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M6 38 L6 20 L12 20 L12 38"
|
||||
fill="currentColor"
|
||||
opacity={0.4}
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M16 38 L16 14 L22 14 L22 38"
|
||||
fill="currentColor"
|
||||
opacity={0.6}
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M26 38 L26 24 L32 24 L32 38"
|
||||
fill="currentColor"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M36 38 L36 10 L42 10 L42 38"
|
||||
fill="currentColor"
|
||||
opacity={0.8}
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 38 L44 38"
|
||||
opacity={0.3}
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No metrics data yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-md mx-auto">
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start sending metrics via OpenTelemetry, you{"'"}ll
|
||||
see a summary of which services are reporting, what metrics they
|
||||
collect, and more.
|
||||
see coverage, categories, and per-service breakdowns here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxMetrics: number = Math.max(
|
||||
...serviceSummaries.map((s: ServiceMetricSummary) => {
|
||||
return s.metricCount;
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<p className="text-sm text-gray-500">Total Metrics</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{/* Hero Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Total Metrics</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-indigo-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{totalMetricCount}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">unique metric types</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<p className="text-sm text-gray-500">Services Reporting</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
Services Reporting
|
||||
</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{serviceSummaries.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">actively sending data</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-5">
|
||||
<p className="text-sm text-gray-500">Avg Metrics per Service</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
Avg per Service
|
||||
</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{serviceSummaries.length > 0
|
||||
? Math.round(totalMetricCount / serviceSummaries.length)
|
||||
: 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">metrics per service</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
No Metrics
|
||||
</p>
|
||||
<div
|
||||
className={`h-9 w-9 rounded-lg flex items-center justify-center ${servicesWithNoMetrics > 0 ? "bg-amber-50" : "bg-gray-50"}`}
|
||||
>
|
||||
<svg
|
||||
className={`h-4.5 w-4.5 ${servicesWithNoMetrics > 0 ? "text-amber-600" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-2 ${servicesWithNoMetrics > 0 ? "text-amber-600" : "text-gray-900"}`}
|
||||
>
|
||||
{servicesWithNoMetrics}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{servicesWithNoMetrics > 0
|
||||
? "services not instrumented"
|
||||
: "all services covered"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric Categories */}
|
||||
{metricCategories.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Metric Categories
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{metricCategories.map((cat: MetricCategory) => {
|
||||
const pct: number =
|
||||
totalMetricCount > 0
|
||||
? Math.round((cat.count / totalMetricCount) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`flex items-center gap-2.5 px-3.5 py-2 rounded-lg ${cat.bgColor}`}
|
||||
>
|
||||
<span className={`text-sm font-semibold ${cat.color}`}>
|
||||
{cat.count}
|
||||
</span>
|
||||
<span className={`text-sm ${cat.color}`}>{cat.name}</span>
|
||||
<span
|
||||
className={`text-xs ${cat.color} opacity-60`}
|
||||
>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* Category distribution bar */}
|
||||
<div className="flex h-2 rounded-full overflow-hidden mt-3">
|
||||
{metricCategories.map((cat: MetricCategory) => {
|
||||
const pct: number =
|
||||
totalMetricCount > 0
|
||||
? (cat.count / totalMetricCount) * 100
|
||||
: 0;
|
||||
const barColorMap: Record<string, string> = {
|
||||
System: "bg-blue-400",
|
||||
Request: "bg-purple-400",
|
||||
Database: "bg-amber-400",
|
||||
Messaging: "bg-green-400",
|
||||
Custom: "bg-gray-300",
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={cat.name}
|
||||
className={`${barColorMap[cat.name] || "bg-gray-300"}`}
|
||||
style={{ width: `${Math.max(pct, 1)}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Cards */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Services Reporting Metrics
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Each service and the metrics it collects
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Coverage and instrumentation per service
|
||||
</p>
|
||||
</div>
|
||||
<AppLink
|
||||
@@ -278,67 +511,84 @@ const MetricsDashboard: FunctionComponent = (): ReactElement => {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{serviceSummaries.map((summary: ServiceMetricSummary) => {
|
||||
const coverage: number =
|
||||
maxMetrics > 0
|
||||
? Math.round((summary.metricCount / maxMetrics) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
<AppLink
|
||||
key={
|
||||
summary.service.id?.toString() ||
|
||||
summary.service._id?.toString()
|
||||
}
|
||||
className="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow"
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_METRICS] as Route,
|
||||
{
|
||||
modelId: new ObjectID(
|
||||
(summary.service._id as string) ||
|
||||
summary.service.id?.toString() ||
|
||||
"",
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<ServiceElement service={summary.service} />
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full font-medium">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-indigo-200 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<ServiceElement service={summary.service} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
{summary.hasSystemMetrics && (
|
||||
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full font-medium">
|
||||
System
|
||||
</span>
|
||||
)}
|
||||
{summary.hasAppMetrics && (
|
||||
<span className="text-xs bg-purple-50 text-purple-700 px-2 py-0.5 rounded-full font-medium">
|
||||
App
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-gray-500">Metrics Collected</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{summary.metricCount}
|
||||
</p>
|
||||
</div>
|
||||
{/* Metric count with relative bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-end justify-between mb-1.5">
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{summary.metricCount}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 mb-1">
|
||||
metrics
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-400 transition-all duration-500"
|
||||
style={{ width: `${Math.max(coverage, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1.5">Sample Metrics</p>
|
||||
{/* Metric name tags */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{summary.metricNames.map((name: string) => {
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700"
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-600 border border-gray-100"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{summary.metricCount > summary.metricNames.length && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-400">
|
||||
+{summary.metricCount - summary.metricNames.length} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_METRICS] as Route,
|
||||
{
|
||||
modelId: new ObjectID(
|
||||
(summary.service._id as string) ||
|
||||
summary.service.id?.toString() ||
|
||||
"",
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
View service metrics
|
||||
</AppLink>
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -17,12 +17,9 @@ 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 AnalyticsModelAPI 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";
|
||||
@@ -38,6 +35,7 @@ interface ServiceProfileSummary {
|
||||
profileCount: number;
|
||||
latestProfileTime: Date | null;
|
||||
profileTypes: Array<string>;
|
||||
totalSamples: number;
|
||||
}
|
||||
|
||||
interface FunctionHotspot {
|
||||
@@ -49,11 +47,23 @@ interface FunctionHotspot {
|
||||
frameType: string;
|
||||
}
|
||||
|
||||
interface ProfileTypeStats {
|
||||
type: string;
|
||||
count: number;
|
||||
displayName: string;
|
||||
badgeColor: string;
|
||||
}
|
||||
|
||||
const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceProfileSummary>
|
||||
>([]);
|
||||
const [hotspots, setHotspots] = useState<Array<FunctionHotspot>>([]);
|
||||
const [profileTypeStats, setProfileTypeStats] = useState<
|
||||
Array<ProfileTypeStats>
|
||||
>([]);
|
||||
const [totalProfileCount, setTotalProfileCount] = useState<number>(0);
|
||||
const [totalSampleCount, setTotalSampleCount] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
@@ -65,28 +75,23 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1);
|
||||
|
||||
// Load services
|
||||
const servicesResult: ListResult<Service> = 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<Service> = servicesResult.data || [];
|
||||
|
||||
// Load recent profiles (last 1 hour) to build per-service summaries
|
||||
const profilesResult: AnalyticsListResult<Profile> =
|
||||
await AnalyticsModelAPI.getList({
|
||||
const [servicesResult, profilesResult] = await Promise.all([
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
AnalyticsModelAPI.getList({
|
||||
modelType: Profile,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
@@ -103,12 +108,18 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
sort: {
|
||||
startTime: SortOrder.Descending,
|
||||
},
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
const services: Array<Service> = servicesResult.data || [];
|
||||
const profiles: Array<Profile> = profilesResult.data || [];
|
||||
|
||||
setTotalProfileCount(profiles.length);
|
||||
|
||||
// Build per-service summaries
|
||||
const summaryMap: Map<string, ServiceProfileSummary> = new Map();
|
||||
const typeCountMap: Map<string, number> = new Map();
|
||||
let totalSamples: number = 0;
|
||||
|
||||
for (const service of services) {
|
||||
const serviceId: string = service.id?.toString() || "";
|
||||
@@ -117,6 +128,7 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
profileCount: 0,
|
||||
latestProfileTime: null,
|
||||
profileTypes: [],
|
||||
totalSamples: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,6 +143,10 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
|
||||
summary.profileCount += 1;
|
||||
|
||||
const samples: number = (profile.sampleCount as number) || 0;
|
||||
summary.totalSamples += samples;
|
||||
totalSamples += samples;
|
||||
|
||||
const profileTime: Date | undefined = profile.startTime
|
||||
? new Date(profile.startTime)
|
||||
: undefined;
|
||||
@@ -144,29 +160,50 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
}
|
||||
|
||||
const profileType: string = profile.profileType || "";
|
||||
|
||||
if (profileType && !summary.profileTypes.includes(profileType)) {
|
||||
summary.profileTypes.push(profileType);
|
||||
}
|
||||
|
||||
// Track global type stats
|
||||
typeCountMap.set(
|
||||
profileType,
|
||||
(typeCountMap.get(profileType) || 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
setTotalSampleCount(totalSamples);
|
||||
|
||||
// Build profile type stats
|
||||
const typeStats: Array<ProfileTypeStats> = Array.from(
|
||||
typeCountMap.entries(),
|
||||
)
|
||||
.map(([type, count]: [string, number]) => {
|
||||
return {
|
||||
type,
|
||||
count,
|
||||
displayName: ProfileUtil.getProfileTypeDisplayName(type),
|
||||
badgeColor: ProfileUtil.getProfileTypeBadgeColor(type),
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(a: ProfileTypeStats, b: ProfileTypeStats) => b.count - a.count,
|
||||
);
|
||||
|
||||
setProfileTypeStats(typeStats);
|
||||
|
||||
// Only show services that have profiles
|
||||
const summariesWithData: Array<ServiceProfileSummary> = Array.from(
|
||||
summaryMap.values(),
|
||||
).filter((s: ServiceProfileSummary) => {
|
||||
return s.profileCount > 0;
|
||||
});
|
||||
).filter((s: ServiceProfileSummary) => s.profileCount > 0);
|
||||
|
||||
// Sort by profile count descending
|
||||
summariesWithData.sort(
|
||||
(a: ServiceProfileSummary, b: ServiceProfileSummary) => {
|
||||
return b.profileCount - a.profileCount;
|
||||
},
|
||||
(a: ServiceProfileSummary, b: ServiceProfileSummary) =>
|
||||
b.profileCount - a.profileCount,
|
||||
);
|
||||
|
||||
setServiceSummaries(summariesWithData);
|
||||
|
||||
// Load top hotspots (function list) across all services
|
||||
// Load top hotspots
|
||||
try {
|
||||
const hotspotsResponse: HTTPResponse<JSONObject> | HTTPErrorResponse =
|
||||
await API.post({
|
||||
@@ -193,7 +230,6 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
] || []) as unknown as Array<FunctionHotspot>;
|
||||
setHotspots(functions);
|
||||
} catch {
|
||||
// Hotspots are optional - don't fail the whole page
|
||||
setHotspots([]);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -224,44 +260,184 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
|
||||
if (serviceSummaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-12 text-center">
|
||||
<div className="text-gray-400 text-5xl mb-4">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12"
|
||||
className="h-8 w-8 text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No performance data yet
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No profiling data yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-md mx-auto">
|
||||
Once your services start sending profiling data, you{"'"}ll see a
|
||||
summary of which services are being profiled, their performance
|
||||
hotspots, and more.
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start sending profiling data, you{"'"}ll see
|
||||
performance hotspots, resource usage patterns, and optimization
|
||||
opportunities.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxProfiles: number = Math.max(
|
||||
...serviceSummaries.map((s: ServiceProfileSummary) => s.profileCount),
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Hero Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Profiles</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-indigo-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-indigo-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{totalProfileCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">last hour</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Services</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-green-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-green-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{serviceSummaries.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">being profiled</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Samples</p>
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 flex items-center justify-center">
|
||||
<svg
|
||||
className="h-4.5 w-4.5 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{totalSampleCount >= 1_000_000
|
||||
? `${(totalSampleCount / 1_000_000).toFixed(1)}M`
|
||||
: totalSampleCount >= 1_000
|
||||
? `${(totalSampleCount / 1_000).toFixed(1)}K`
|
||||
: totalSampleCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">total samples</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-gray-500">Hotspots</p>
|
||||
<div
|
||||
className={`h-9 w-9 rounded-lg flex items-center justify-center ${hotspots.length > 0 ? "bg-orange-50" : "bg-gray-50"}`}
|
||||
>
|
||||
<svg
|
||||
className={`h-4.5 w-4.5 ${hotspots.length > 0 ? "text-orange-600" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.362 5.214A8.252 8.252 0 0112 21 8.25 8.25 0 016.038 7.048 6.51 6.51 0 009 4.572c.163.07.322.148.476.232M12 18.75a6.743 6.743 0 002.14-1.234M12 18.75a6.72 6.72 0 01-2.14-1.234M12 18.75V21m-4.773-4.227l-1.591 1.591M5.636 5.636L4.045 4.045m0 15.91l1.591-1.591M18.364 5.636l1.591-1.591M21 12h-2.25M4.5 12H2.25"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">
|
||||
{hotspots.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">functions identified</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Type Distribution */}
|
||||
{profileTypeStats.length > 0 && (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Profile Types Collected
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{profileTypeStats.map((stat: ProfileTypeStats) => {
|
||||
const pct: number =
|
||||
totalProfileCount > 0
|
||||
? Math.round((stat.count / totalProfileCount) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={stat.type}
|
||||
className={`flex items-center gap-2.5 px-3.5 py-2 rounded-lg ${stat.badgeColor}`}
|
||||
>
|
||||
<span className="text-sm font-semibold">{stat.count}</span>
|
||||
<span className="text-sm">{stat.displayName}</span>
|
||||
<span className="text-xs opacity-60">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Cards */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Services Being Profiled
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Performance data collected in the last hour
|
||||
</p>
|
||||
</div>
|
||||
@@ -276,42 +452,58 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{serviceSummaries.map((summary: ServiceProfileSummary) => {
|
||||
const coverage: number =
|
||||
maxProfiles > 0
|
||||
? Math.round((summary.profileCount / maxProfiles) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
<AppLink
|
||||
key={summary.service.id?.toString()}
|
||||
className="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow"
|
||||
className="block"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_PROFILES] as Route,
|
||||
{
|
||||
modelId: new ObjectID(summary.service._id as string),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<ServiceElement service={summary.service} />
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full font-medium">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Profiles</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{summary.profileCount}
|
||||
</p>
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 hover:border-indigo-200 hover:shadow-md transition-all duration-200">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<ServiceElement service={summary.service} />
|
||||
<span className="text-xs bg-green-50 text-green-700 px-2 py-0.5 rounded-full font-medium">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Last Captured</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
{summary.latestProfileTime
|
||||
? OneUptimeDate.getDateAsLocalFormattedString(
|
||||
summary.latestProfileTime,
|
||||
true,
|
||||
)
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1.5">
|
||||
Profile Types Collected
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Profiles</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{summary.profileCount}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">Samples</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{summary.totalSamples >= 1_000
|
||||
? `${(summary.totalSamples / 1_000).toFixed(1)}K`
|
||||
: summary.totalSamples.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile volume bar */}
|
||||
<div className="mb-3">
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-400 transition-all duration-500"
|
||||
style={{ width: `${Math.max(coverage, 3)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile type badges */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{summary.profileTypes.map((profileType: string) => {
|
||||
const badgeColor: string =
|
||||
@@ -319,29 +511,22 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
return (
|
||||
<span
|
||||
key={profileType}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${badgeColor}`}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${badgeColor}`}
|
||||
>
|
||||
{ProfileUtil.getProfileTypeDisplayName(profileType)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_PROFILES] as Route,
|
||||
{
|
||||
modelId: new ObjectID(summary.service._id as string),
|
||||
},
|
||||
)}
|
||||
>
|
||||
View service profiles
|
||||
</AppLink>
|
||||
{summary.latestProfileTime && (
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Last captured{" "}
|
||||
{OneUptimeDate.fromNow(summary.latestProfileTime)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -350,70 +535,80 @@ const ProfilesDashboard: FunctionComponent = (): ReactElement => {
|
||||
{/* Top Hotspots */}
|
||||
{hotspots.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Top Performance Hotspots
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Functions using the most resources across all services in the last
|
||||
hour
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-medium">#</th>
|
||||
<th className="px-5 py-3 font-medium">Function</th>
|
||||
<th className="px-5 py-3 font-medium">Source File</th>
|
||||
<th className="px-5 py-3 text-right font-medium">Own Time</th>
|
||||
<th className="px-5 py-3 text-right font-medium">
|
||||
Total Time
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right font-medium">
|
||||
Occurrences
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hotspots.map((fn: FunctionHotspot, index: number) => {
|
||||
const maxSelf: number = hotspots[0]?.selfValue || 1;
|
||||
const barWidth: number = (fn.selfValue / maxSelf) * 100;
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Functions consuming the most resources across all services
|
||||
</p>
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{hotspots.map((fn: FunctionHotspot, index: number) => {
|
||||
const maxSelf: number = hotspots[0]?.selfValue || 1;
|
||||
const barWidth: number = (fn.selfValue / maxSelf) * 100;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={`${fn.functionName}-${fn.fileName}-${index}`}
|
||||
className="border-t border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-5 py-3 text-gray-400 font-mono text-xs">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="font-mono text-xs text-gray-900 truncate max-w-xs">
|
||||
{fn.functionName}
|
||||
return (
|
||||
<div
|
||||
key={`${fn.functionName}-${fn.fileName}-${index}`}
|
||||
className="px-5 py-3.5 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-1.5">
|
||||
<div className="min-w-0 flex-1 mr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 font-mono w-5 flex-shrink-0">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<p className="font-mono text-sm text-gray-900 truncate">
|
||||
{fn.functionName}
|
||||
</p>
|
||||
{fn.frameType && (
|
||||
<span className="flex-shrink-0 text-xs px-1.5 py-0.5 rounded font-medium bg-gray-100 text-gray-600">
|
||||
{fn.frameType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{fn.fileName && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 ml-7 truncate">
|
||||
{fn.fileName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-5 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold font-mono text-gray-900">
|
||||
{fn.selfValue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">own time</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-mono text-gray-700">
|
||||
{fn.totalValue.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">total</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-mono text-gray-700">
|
||||
{fn.sampleCount.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">samples</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-7">
|
||||
<div className="w-full h-1 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="mt-1 h-1 rounded-full bg-orange-400"
|
||||
className="h-full rounded-full bg-orange-400"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-gray-500 text-xs truncate max-w-xs">
|
||||
{fn.fileName || "-"}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right font-mono text-xs text-gray-900">
|
||||
{fn.selfValue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right font-mono text-xs text-gray-700">
|
||||
{fn.totalValue.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right font-mono text-xs text-gray-700">
|
||||
{fn.sampleCount.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,12 +12,9 @@ import API from "Common/Utils/API";
|
||||
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 Span, { SpanStatus } from "Common/Models/AnalyticsModels/Span";
|
||||
import AnalyticsModelAPI, {
|
||||
ListResult as AnalyticsListResult,
|
||||
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
|
||||
import AnalyticsModelAPI 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";
|
||||
@@ -33,6 +30,9 @@ interface ServiceTraceSummary {
|
||||
totalTraces: number;
|
||||
errorTraces: number;
|
||||
latestTraceTime: Date | null;
|
||||
p50Nanos: number;
|
||||
p95Nanos: number;
|
||||
durations: Array<number>;
|
||||
}
|
||||
|
||||
interface RecentTrace {
|
||||
@@ -44,6 +44,33 @@ interface RecentTrace {
|
||||
durationNano: number;
|
||||
}
|
||||
|
||||
const formatDuration: (nanos: number) => string = (nanos: number): string => {
|
||||
if (nanos >= 1_000_000_000) {
|
||||
return `${(nanos / 1_000_000_000).toFixed(2)}s`;
|
||||
}
|
||||
if (nanos >= 1_000_000) {
|
||||
return `${(nanos / 1_000_000).toFixed(1)}ms`;
|
||||
}
|
||||
if (nanos >= 1_000) {
|
||||
return `${(nanos / 1_000).toFixed(0)}us`;
|
||||
}
|
||||
return `${nanos}ns`;
|
||||
};
|
||||
|
||||
const getPercentile: (arr: Array<number>, p: number) => number = (
|
||||
arr: Array<number>,
|
||||
p: number,
|
||||
): number => {
|
||||
if (arr.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const sorted: Array<number> = [...arr].sort(
|
||||
(a: number, b: number) => a - b,
|
||||
);
|
||||
const idx: number = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, idx)] || 0;
|
||||
};
|
||||
|
||||
const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const [serviceSummaries, setServiceSummaries] = useState<
|
||||
Array<ServiceTraceSummary>
|
||||
@@ -55,22 +82,14 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
[],
|
||||
);
|
||||
const [services, setServices] = useState<Array<Service>>([]);
|
||||
const [totalRequests, setTotalRequests] = useState<number>(0);
|
||||
const [totalErrors, setTotalErrors] = useState<number>(0);
|
||||
const [globalP50, setGlobalP50] = useState<number>(0);
|
||||
const [globalP95, setGlobalP95] = useState<number>(0);
|
||||
const [globalP99, setGlobalP99] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
const formatDuration: (nanos: number) => string = (nanos: number): string => {
|
||||
if (nanos >= 1_000_000_000) {
|
||||
return `${(nanos / 1_000_000_000).toFixed(2)}s`;
|
||||
}
|
||||
if (nanos >= 1_000_000) {
|
||||
return `${(nanos / 1_000_000).toFixed(1)}ms`;
|
||||
}
|
||||
if (nanos >= 1_000) {
|
||||
return `${(nanos / 1_000).toFixed(0)}us`;
|
||||
}
|
||||
return `${nanos}ns`;
|
||||
};
|
||||
|
||||
const loadDashboard: () => Promise<void> = async (): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -79,29 +98,23 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const now: Date = OneUptimeDate.getCurrentDate();
|
||||
const oneHourAgo: Date = OneUptimeDate.addRemoveHours(now, -1);
|
||||
|
||||
// Load services
|
||||
const servicesResult: ListResult<Service> = 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 loadedServices: Array<Service> = servicesResult.data || [];
|
||||
setServices(loadedServices);
|
||||
|
||||
// Load recent spans (last 1 hour) to build per-service summaries
|
||||
const spansResult: AnalyticsListResult<Span> =
|
||||
await AnalyticsModelAPI.getList({
|
||||
const [servicesResult, spansResult] = await Promise.all([
|
||||
ModelAPI.getList({
|
||||
modelType: Service,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
},
|
||||
select: {
|
||||
serviceColor: true,
|
||||
name: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
sort: {
|
||||
name: SortOrder.Ascending,
|
||||
},
|
||||
}),
|
||||
AnalyticsModelAPI.getList({
|
||||
modelType: Span,
|
||||
query: {
|
||||
projectId: ProjectUtil.getCurrentProjectId()!,
|
||||
@@ -122,11 +135,15 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
sort: {
|
||||
startTime: SortOrder.Descending,
|
||||
},
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
const loadedServices: Array<Service> = servicesResult.data || [];
|
||||
setServices(loadedServices);
|
||||
|
||||
const allSpans: Array<Span> = spansResult.data || [];
|
||||
|
||||
// Build per-service summaries from all spans
|
||||
// Build per-service summaries
|
||||
const summaryMap: Map<string, ServiceTraceSummary> = new Map();
|
||||
|
||||
for (const service of loadedServices) {
|
||||
@@ -136,44 +153,52 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
totalTraces: 0,
|
||||
errorTraces: 0,
|
||||
latestTraceTime: null,
|
||||
p50Nanos: 0,
|
||||
p95Nanos: 0,
|
||||
durations: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Track unique traces per service for counting
|
||||
const serviceTraceIds: Map<string, Set<string>> = new Map();
|
||||
const serviceErrorTraceIds: Map<string, Set<string>> = new Map();
|
||||
|
||||
const errorTraces: Array<RecentTrace> = [];
|
||||
const allTraces: Array<RecentTrace> = [];
|
||||
const seenTraceIds: Set<string> = new Set();
|
||||
const seenErrorTraceIds: Set<string> = new Set();
|
||||
const allDurations: Array<number> = [];
|
||||
|
||||
for (const span of allSpans) {
|
||||
const serviceId: string = span.serviceId?.toString() || "";
|
||||
const traceId: string = span.traceId?.toString() || "";
|
||||
const duration: number = (span.durationUnixNano as number) || 0;
|
||||
const summary: ServiceTraceSummary | undefined =
|
||||
summaryMap.get(serviceId);
|
||||
|
||||
if (duration > 0) {
|
||||
allDurations.push(duration);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
// Count unique traces per service
|
||||
if (!serviceTraceIds.has(serviceId)) {
|
||||
serviceTraceIds.set(serviceId, new Set());
|
||||
}
|
||||
|
||||
if (!serviceErrorTraceIds.has(serviceId)) {
|
||||
serviceErrorTraceIds.set(serviceId, new Set());
|
||||
}
|
||||
|
||||
const traceSet: Set<string> = serviceTraceIds.get(serviceId)!;
|
||||
|
||||
if (!traceSet.has(traceId)) {
|
||||
traceSet.add(traceId);
|
||||
summary.totalTraces += 1;
|
||||
}
|
||||
|
||||
if (span.statusCode === SpanStatus.Error) {
|
||||
const errorSet: Set<string> = serviceErrorTraceIds.get(serviceId)!;
|
||||
if (duration > 0) {
|
||||
summary.durations.push(duration);
|
||||
}
|
||||
|
||||
if (span.statusCode === SpanStatus.Error) {
|
||||
const errorSet: Set<string> =
|
||||
serviceErrorTraceIds.get(serviceId)!;
|
||||
if (!errorSet.has(traceId)) {
|
||||
errorSet.add(traceId);
|
||||
summary.errorTraces += 1;
|
||||
@@ -183,7 +208,6 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const spanTime: Date | undefined = span.startTime
|
||||
? new Date(span.startTime)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
spanTime &&
|
||||
(!summary.latestTraceTime || spanTime > summary.latestTraceTime)
|
||||
@@ -192,67 +216,84 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* For the recent traces lists, pick the first span per trace
|
||||
* (which is the most recent since we sort desc)
|
||||
*/
|
||||
if (!seenTraceIds.has(traceId) && traceId) {
|
||||
seenTraceIds.add(traceId);
|
||||
|
||||
const traceRecord: RecentTrace = {
|
||||
traceId: traceId,
|
||||
allTraces.push({
|
||||
traceId,
|
||||
name: span.name?.toString() || "Unknown",
|
||||
serviceId: serviceId,
|
||||
serviceId,
|
||||
startTime: span.startTime ? new Date(span.startTime) : new Date(),
|
||||
statusCode: span.statusCode || SpanStatus.Unset,
|
||||
durationNano: (span.durationUnixNano as number) || 0,
|
||||
};
|
||||
|
||||
allTraces.push(traceRecord);
|
||||
durationNano: duration,
|
||||
});
|
||||
}
|
||||
|
||||
// Collect error spans, deduped by trace
|
||||
if (
|
||||
span.statusCode === SpanStatus.Error &&
|
||||
traceId &&
|
||||
!seenErrorTraceIds.has(traceId)
|
||||
) {
|
||||
seenErrorTraceIds.add(traceId);
|
||||
|
||||
errorTraces.push({
|
||||
traceId: traceId,
|
||||
traceId,
|
||||
name: span.name?.toString() || "Unknown",
|
||||
serviceId: serviceId,
|
||||
serviceId,
|
||||
startTime: span.startTime ? new Date(span.startTime) : new Date(),
|
||||
statusCode: span.statusCode,
|
||||
durationNano: (span.durationUnixNano as number) || 0,
|
||||
durationNano: duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only show services that have traces
|
||||
// Compute global percentiles
|
||||
setGlobalP50(getPercentile(allDurations, 50));
|
||||
setGlobalP95(getPercentile(allDurations, 95));
|
||||
setGlobalP99(getPercentile(allDurations, 99));
|
||||
|
||||
// Compute per-service percentiles and filter
|
||||
const summariesWithData: Array<ServiceTraceSummary> = Array.from(
|
||||
summaryMap.values(),
|
||||
).filter((s: ServiceTraceSummary) => {
|
||||
return s.totalTraces > 0;
|
||||
});
|
||||
)
|
||||
.filter((s: ServiceTraceSummary) => s.totalTraces > 0)
|
||||
.map((s: ServiceTraceSummary) => {
|
||||
return {
|
||||
...s,
|
||||
p50Nanos: getPercentile(s.durations, 50),
|
||||
p95Nanos: getPercentile(s.durations, 95),
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by total traces descending
|
||||
// Sort: highest error rate first, then by total traces
|
||||
summariesWithData.sort(
|
||||
(a: ServiceTraceSummary, b: ServiceTraceSummary) => {
|
||||
const aErrorRate: number =
|
||||
a.totalTraces > 0 ? a.errorTraces / a.totalTraces : 0;
|
||||
const bErrorRate: number =
|
||||
b.totalTraces > 0 ? b.errorTraces / b.totalTraces : 0;
|
||||
if (bErrorRate !== aErrorRate) {
|
||||
return bErrorRate - aErrorRate;
|
||||
}
|
||||
return b.totalTraces - a.totalTraces;
|
||||
},
|
||||
);
|
||||
|
||||
setServiceSummaries(summariesWithData);
|
||||
setRecentErrorTraces(errorTraces.slice(0, 10));
|
||||
let totalReqs: number = 0;
|
||||
let totalErrs: number = 0;
|
||||
for (const s of summariesWithData) {
|
||||
totalReqs += s.totalTraces;
|
||||
totalErrs += s.errorTraces;
|
||||
}
|
||||
setTotalRequests(totalReqs);
|
||||
setTotalErrors(totalErrs);
|
||||
|
||||
setServiceSummaries(summariesWithData);
|
||||
setRecentErrorTraces(errorTraces.slice(0, 8));
|
||||
|
||||
// Get slowest traces
|
||||
const slowTraces: Array<RecentTrace> = [...allTraces]
|
||||
.sort((a: RecentTrace, b: RecentTrace) => {
|
||||
return b.durationNano - a.durationNano;
|
||||
})
|
||||
.slice(0, 10);
|
||||
.sort(
|
||||
(a: RecentTrace, b: RecentTrace) => b.durationNano - a.durationNano,
|
||||
)
|
||||
.slice(0, 8);
|
||||
setRecentSlowTraces(slowTraces);
|
||||
} catch (err) {
|
||||
setError(API.getFriendlyErrorMessage(err as Error));
|
||||
@@ -268,9 +309,9 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
const getServiceName: (serviceId: string) => string = (
|
||||
serviceId: string,
|
||||
): string => {
|
||||
const service: Service | undefined = services.find((s: Service) => {
|
||||
return s.id?.toString() === serviceId;
|
||||
});
|
||||
const service: Service | undefined = services.find(
|
||||
(s: Service) => s.id?.toString() === serviceId,
|
||||
);
|
||||
return service?.name?.toString() || "Unknown";
|
||||
};
|
||||
|
||||
@@ -291,73 +332,104 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
|
||||
if (serviceSummaries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-12 text-center">
|
||||
<div className="mb-4">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-16 text-center">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mb-5">
|
||||
<svg
|
||||
className="mx-auto h-16 w-16 text-indigo-200"
|
||||
className="h-8 w-8 text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
{/* Three horizontal bars representing a waterfall/trace timeline */}
|
||||
<rect
|
||||
x="4"
|
||||
y="10"
|
||||
width="28"
|
||||
height="5"
|
||||
rx="2.5"
|
||||
strokeWidth={1.5}
|
||||
fill="currentColor"
|
||||
opacity={0.5}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
|
||||
/>
|
||||
<rect
|
||||
x="12"
|
||||
y="20"
|
||||
width="20"
|
||||
height="5"
|
||||
rx="2.5"
|
||||
strokeWidth={1.5}
|
||||
fill="currentColor"
|
||||
opacity={0.7}
|
||||
/>
|
||||
<rect
|
||||
x="20"
|
||||
y="30"
|
||||
width="24"
|
||||
height="5"
|
||||
rx="2.5"
|
||||
strokeWidth={1.5}
|
||||
fill="currentColor"
|
||||
opacity={0.9}
|
||||
/>
|
||||
{/* Connecting lines */}
|
||||
<path d="M18 15 L16 20" strokeWidth={1.5} opacity={0.4} />
|
||||
<path d="M22 25 L24 30" strokeWidth={1.5} opacity={0.4} />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No trace data yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-md mx-auto">
|
||||
<p className="text-sm text-gray-500 max-w-sm mx-auto leading-relaxed">
|
||||
Once your services start sending distributed tracing data, you{"'"}ll
|
||||
see a summary of requests flowing through your system, error rates,
|
||||
and slow operations.
|
||||
see request rates, error rates, latency percentiles, and more.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overallErrorRate: number =
|
||||
totalRequests > 0 ? (totalErrors / totalRequests) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* Service Cards */}
|
||||
<div className="mb-8">
|
||||
{/* Hero Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p className="text-sm font-medium text-gray-500">Requests</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{totalRequests.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">last hour</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${overallErrorRate > 5 ? "border-red-200 bg-red-50" : overallErrorRate > 1 ? "border-amber-200 bg-amber-50" : "border-gray-200 bg-white"}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500">Error Rate</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-1 ${overallErrorRate > 5 ? "text-red-600" : overallErrorRate > 1 ? "text-amber-600" : "text-green-600"}`}
|
||||
>
|
||||
{overallErrorRate.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{totalErrors.toLocaleString()} errors
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p className="text-sm font-medium text-gray-500">P50 Latency</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1">
|
||||
{formatDuration(globalP50)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">median</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${globalP95 > 1_000_000_000 ? "border-amber-200 bg-amber-50" : "border-gray-200 bg-white"}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500">P95 Latency</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-1 ${globalP95 > 1_000_000_000 ? "text-amber-600" : "text-gray-900"}`}
|
||||
>
|
||||
{formatDuration(globalP95)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">95th percentile</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${globalP99 > 2_000_000_000 ? "border-red-200 bg-red-50" : "border-gray-200 bg-white"}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-500">P99 Latency</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-1 ${globalP99 > 2_000_000_000 ? "text-red-600" : "text-gray-900"}`}
|
||||
>
|
||||
{formatDuration(globalP99)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">99th percentile</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Health Table */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Services Overview
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Service Health
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Request activity across your services in the last hour
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Sorted by error rate — services needing attention first
|
||||
</p>
|
||||
</div>
|
||||
<AppLink
|
||||
@@ -369,125 +441,180 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
View all spans
|
||||
</AppLink>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{serviceSummaries.map((summary: ServiceTraceSummary) => {
|
||||
const errorRate: number =
|
||||
summary.totalTraces > 0
|
||||
? (summary.errorTraces / summary.totalTraces) * 100
|
||||
: 0;
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Service
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Requests
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Error Rate
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
P50
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
P95
|
||||
</th>
|
||||
<th className="text-center text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
Status
|
||||
</th>
|
||||
<th className="text-right text-xs font-medium text-gray-500 uppercase tracking-wider px-5 py-3">
|
||||
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{serviceSummaries.map((summary: ServiceTraceSummary) => {
|
||||
const errorRate: number =
|
||||
summary.totalTraces > 0
|
||||
? (summary.errorTraces / summary.totalTraces) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={summary.service.id?.toString()}
|
||||
className="rounded-lg border border-gray-200 bg-white p-5 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<ServiceElement service={summary.service} />
|
||||
{errorRate > 5 ? (
|
||||
<span className="text-xs bg-red-100 text-red-800 px-2 py-0.5 rounded-full font-medium">
|
||||
{errorRate.toFixed(1)}% errors
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded-full font-medium">
|
||||
Healthy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
let healthColor: string = "bg-green-500";
|
||||
let healthLabel: string = "Healthy";
|
||||
let healthBg: string = "bg-green-50 text-green-700";
|
||||
if (errorRate > 10) {
|
||||
healthColor = "bg-red-500";
|
||||
healthLabel = "Critical";
|
||||
healthBg = "bg-red-50 text-red-700";
|
||||
} else if (errorRate > 5) {
|
||||
healthColor = "bg-amber-500";
|
||||
healthLabel = "Degraded";
|
||||
healthBg = "bg-amber-50 text-amber-700";
|
||||
} else if (errorRate > 1) {
|
||||
healthColor = "bg-yellow-400";
|
||||
healthLabel = "Warning";
|
||||
healthBg = "bg-yellow-50 text-yellow-700";
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-3">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Requests</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{summary.totalTraces.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Errors</p>
|
||||
<p
|
||||
className={`text-lg font-semibold ${summary.errorTraces > 0 ? "text-red-600" : "text-gray-900"}`}
|
||||
>
|
||||
{summary.errorTraces.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Last Seen</p>
|
||||
<p className="text-sm text-gray-700">
|
||||
{summary.latestTraceTime
|
||||
? OneUptimeDate.getDateAsLocalFormattedString(
|
||||
summary.latestTraceTime,
|
||||
true,
|
||||
)
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error rate bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Error Rate</span>
|
||||
<span>{errorRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${errorRate > 5 ? "bg-red-500" : errorRate > 0 ? "bg-yellow-400" : "bg-green-400"}`}
|
||||
style={{
|
||||
width: `${Math.max(errorRate, errorRate > 0 ? 2 : 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||
<AppLink
|
||||
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_TRACES] as Route,
|
||||
{
|
||||
modelId: new ObjectID(summary.service._id as string),
|
||||
},
|
||||
)}
|
||||
return (
|
||||
<tr
|
||||
key={summary.service.id?.toString()}
|
||||
className="hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
View service traces
|
||||
</AppLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<td className="px-5 py-3.5">
|
||||
<ServiceElement service={summary.service} />
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{summary.totalTraces.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${errorRate > 10 ? "bg-red-500" : errorRate > 5 ? "bg-amber-400" : errorRate > 0 ? "bg-yellow-400" : "bg-green-400"}`}
|
||||
style={{
|
||||
width: `${Math.max(errorRate, errorRate > 0 ? 3 : 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-medium ${errorRate > 5 ? "text-red-600" : "text-gray-900"}`}
|
||||
>
|
||||
{errorRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-mono text-gray-700">
|
||||
{formatDuration(summary.p50Nanos)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<span className="text-sm font-mono text-gray-700">
|
||||
{formatDuration(summary.p95Nanos)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${healthBg}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${healthColor}`}
|
||||
/>
|
||||
{healthLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right">
|
||||
<AppLink
|
||||
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SERVICE_VIEW_TRACES] as Route,
|
||||
{
|
||||
modelId: new ObjectID(
|
||||
summary.service._id as string,
|
||||
),
|
||||
},
|
||||
)}
|
||||
>
|
||||
View
|
||||
</AppLink>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout for errors and slow traces */}
|
||||
{/* Two-column: Errors + Slow Requests */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Errors */}
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Recent Errors
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Failed requests in the last hour
|
||||
</p>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Recent Errors
|
||||
</h3>
|
||||
{recentErrorTraces.length > 0 && (
|
||||
<span className="text-xs bg-red-50 text-red-700 px-2 py-0.5 rounded-full font-medium">
|
||||
{recentErrorTraces.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recentErrorTraces.length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-10 text-center">
|
||||
<div className="mx-auto w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mb-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
No errors in the last hour
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Looking good!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentErrorTraces.map((trace: RecentTrace, index: number) => {
|
||||
return (
|
||||
<AppLink
|
||||
key={`${trace.traceId}-${index}`}
|
||||
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
className="block px-4 py-3 hover:bg-red-50/30 transition-colors"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACE_VIEW]!,
|
||||
{
|
||||
modelId: trace.traceId,
|
||||
},
|
||||
{ modelId: trace.traceId },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -510,10 +637,7 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
{formatDuration(trace.durationNano)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{OneUptimeDate.getDateAsLocalFormattedString(
|
||||
trace.startTime,
|
||||
true,
|
||||
)}
|
||||
{OneUptimeDate.fromNow(trace.startTime)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -525,25 +649,23 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slowest Traces */}
|
||||
{/* Slowest Requests */}
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
<h3 className="text-base font-semibold text-gray-900">
|
||||
Slowest Requests
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Longest running operations in the last hour
|
||||
</p>
|
||||
</div>
|
||||
{recentSlowTraces.length === 0 ? (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-8 text-center">
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-10 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
No traces found in the last hour
|
||||
No traces in the last hour
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="divide-y divide-gray-50">
|
||||
{recentSlowTraces.map((trace: RecentTrace, index: number) => {
|
||||
const maxDuration: number =
|
||||
recentSlowTraces[0]?.durationNano || 1;
|
||||
@@ -553,15 +675,13 @@ const TracesDashboard: FunctionComponent = (): ReactElement => {
|
||||
return (
|
||||
<AppLink
|
||||
key={`${trace.traceId}-slow-${index}`}
|
||||
className="block px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
className="block px-4 py-3 hover:bg-amber-50/30 transition-colors"
|
||||
to={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.TRACE_VIEW]!,
|
||||
{
|
||||
modelId: trace.traceId,
|
||||
},
|
||||
{ modelId: trace.traceId },
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center space-x-3 min-w-0">
|
||||
<SpanStatusElement
|
||||
spanStatusCode={trace.statusCode}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BASE_URL } from "../../Config";
|
||||
import { Page, expect, test } from "@playwright/test";
|
||||
import { Page, Response, expect, test } from "@playwright/test";
|
||||
import URL from "Common/Types/API/URL";
|
||||
|
||||
test.describe("check admin dashboard", () => {
|
||||
@@ -9,7 +9,7 @@ test.describe("check admin dashboard", () => {
|
||||
page: Page;
|
||||
}) => {
|
||||
page.setDefaultNavigationTimeout(120000); // 2 minutes
|
||||
const response = await page.goto(
|
||||
const response: Response | null = await page.goto(
|
||||
`${URL.fromString(BASE_URL.toString()).addRoute("/admin/env.js").toString()}`,
|
||||
);
|
||||
expect(response?.ok()).toBeTruthy();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BASE_URL } from "../../Config";
|
||||
import { Page, expect, test } from "@playwright/test";
|
||||
import { Page, Response, expect, test } from "@playwright/test";
|
||||
import URL from "Common/Types/API/URL";
|
||||
|
||||
test.describe("check public dashboard", () => {
|
||||
@@ -9,7 +9,7 @@ test.describe("check public dashboard", () => {
|
||||
page: Page;
|
||||
}) => {
|
||||
page.setDefaultNavigationTimeout(120000); // 2 minutes
|
||||
const response = await page.goto(
|
||||
const response: Response | null = await page.goto(
|
||||
`${URL.fromString(BASE_URL.toString())
|
||||
.addRoute("/public-dashboard")
|
||||
.toString()}`,
|
||||
|
||||
Reference in New Issue
Block a user