Merge pull request #2363 from OneUptime/k8s-impl

K8s impl
This commit is contained in:
Simon Larsen
2026-03-19 09:26:20 +00:00
committed by GitHub
62 changed files with 8937 additions and 584 deletions

View File

@@ -1,18 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "mobile-app",
"runtimeExecutable": "bash",
"runtimeArgs": ["-c", "cd MobileApp && npx expo start --port 8081"],
"port": 8081
},
{
"name": "dashboard",
"runtimeExecutable": "bash",
"runtimeArgs": ["-c", "cd App/FeatureSet/Dashboard && npm run dev"],
"port": 3002,
"autoPort": false
}
]
}

View File

@@ -0,0 +1,276 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import Card from "Common/UI/Components/Card/Card";
import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer";
import {
KubernetesContainerPort,
KubernetesContainerSpec,
KubernetesContainerStatus,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
export interface ComponentProps {
containers: Array<KubernetesContainerSpec>;
initContainers: Array<KubernetesContainerSpec>;
containerStatuses?: Array<KubernetesContainerStatus> | undefined;
initContainerStatuses?: Array<KubernetesContainerStatus> | undefined;
}
interface ContainerCardProps {
container: KubernetesContainerSpec;
status?: KubernetesContainerStatus | undefined;
isInit: boolean;
}
const ContainerCard: FunctionComponent<ContainerCardProps> = (
props: ContainerCardProps,
): ReactElement => {
const [showEnv, setShowEnv] = useState<boolean>(false);
const [showMounts, setShowMounts] = useState<boolean>(false);
const envRecord: Record<string, string> = {};
for (const env of props.container.env) {
envRecord[env.name] = env.value;
}
const hasResources: boolean =
Object.keys(props.container.resources.requests).length > 0 ||
Object.keys(props.container.resources.limits).length > 0;
return (
<Card
title={`${props.isInit ? "Init Container: " : "Container: "}${props.container.name}`}
description={props.container.image}
>
<div className="space-y-4">
{/* Status */}
{props.status && (
<div className="flex gap-4 text-sm">
<div>
<span className="text-gray-500">State:</span>{" "}
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
props.status.state === "running"
? "bg-green-50 text-green-700"
: props.status.state === "waiting"
? "bg-yellow-50 text-yellow-700"
: "bg-red-50 text-red-700"
}`}
>
{props.status.state}
</span>
</div>
<div>
<span className="text-gray-500">Ready:</span>{" "}
<span
className={
props.status.ready ? "text-green-700" : "text-red-700"
}
>
{props.status.ready ? "Yes" : "No"}
</span>
</div>
<div>
<span className="text-gray-500">Restarts:</span>{" "}
<span
className={
props.status.restartCount > 0
? "text-yellow-700"
: "text-gray-700"
}
>
{props.status.restartCount}
</span>
</div>
</div>
)}
{/* Command & Args */}
{props.container.command.length > 0 && (
<div className="text-sm">
<span className="text-gray-500 font-medium">Command:</span>{" "}
<code className="text-xs bg-gray-100 px-1 py-0.5 rounded">
{props.container.command.join(" ")}
</code>
</div>
)}
{props.container.args.length > 0 && (
<div className="text-sm">
<span className="text-gray-500 font-medium">Args:</span>{" "}
<code className="text-xs bg-gray-100 px-1 py-0.5 rounded">
{props.container.args.join(" ")}
</code>
</div>
)}
{/* Ports */}
{props.container.ports.length > 0 && (
<div className="text-sm">
<span className="text-gray-500 font-medium">Ports:</span>{" "}
{props.container.ports.map(
(port: KubernetesContainerPort, idx: number) => {
return (
<span
key={idx}
className="inline-flex px-2 py-0.5 text-xs font-medium rounded bg-blue-50 text-blue-700 mr-1"
>
{port.name ? `${port.name}: ` : ""}
{port.containerPort}/{port.protocol}
</span>
);
},
)}
</div>
)}
{/* Resources */}
{hasResources && (
<div className="grid grid-cols-2 gap-4 text-sm">
{Object.keys(props.container.resources.requests).length > 0 && (
<div>
<span className="text-gray-500 font-medium block mb-1">
Requests:
</span>
<DictionaryOfStringsViewer
value={props.container.resources.requests}
/>
</div>
)}
{Object.keys(props.container.resources.limits).length > 0 && (
<div>
<span className="text-gray-500 font-medium block mb-1">
Limits:
</span>
<DictionaryOfStringsViewer
value={props.container.resources.limits}
/>
</div>
)}
</div>
)}
{/* Environment Variables (expandable) */}
{props.container.env.length > 0 && (
<div>
<button
onClick={() => {
setShowEnv(!showEnv);
}}
className="text-sm text-indigo-600 hover:text-indigo-900 font-medium"
>
{showEnv ? "Hide" : "Show"} Environment Variables (
{props.container.env.length})
</button>
{showEnv && (
<div className="mt-2">
<DictionaryOfStringsViewer value={envRecord} />
</div>
)}
</div>
)}
{/* Volume Mounts (expandable) */}
{props.container.volumeMounts.length > 0 && (
<div>
<button
onClick={() => {
setShowMounts(!showMounts);
}}
className="text-sm text-indigo-600 hover:text-indigo-900 font-medium"
>
{showMounts ? "Hide" : "Show"} Volume Mounts (
{props.container.volumeMounts.length})
</button>
{showMounts && (
<div className="mt-2 space-y-1">
{props.container.volumeMounts.map(
(
mount: {
name: string;
mountPath: string;
readOnly: boolean;
},
idx: number,
) => {
return (
<div key={idx} className="text-sm flex gap-2">
<span className="font-medium text-gray-700">
{mount.name}
</span>
<span className="text-gray-500"></span>
<code className="text-xs bg-gray-100 px-1 py-0.5 rounded">
{mount.mountPath}
</code>
{mount.readOnly && (
<span className="text-xs text-gray-400">
(read-only)
</span>
)}
</div>
);
},
)}
</div>
)}
</div>
)}
</div>
</Card>
);
};
const KubernetesContainersTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
if (props.containers.length === 0 && props.initContainers.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No container information available.
</div>
);
}
const getStatus: (
name: string,
isInit: boolean,
) => KubernetesContainerStatus | undefined = (
name: string,
isInit: boolean,
): KubernetesContainerStatus | undefined => {
const statuses: Array<KubernetesContainerStatus> | undefined = isInit
? props.initContainerStatuses
: props.containerStatuses;
return statuses?.find((s: KubernetesContainerStatus) => {
return s.name === name;
});
};
return (
<div className="space-y-4">
{props.initContainers.map(
(container: KubernetesContainerSpec, index: number) => {
return (
<ContainerCard
key={`init-${index}`}
container={container}
status={getStatus(container.name, true)}
isInit={true}
/>
);
},
)}
{props.containers.map(
(container: KubernetesContainerSpec, index: number) => {
return (
<ContainerCard
key={`container-${index}`}
container={container}
status={getStatus(container.name, false)}
isInit={false}
/>
);
},
)}
</div>
);
};
export default KubernetesContainersTab;

View File

@@ -0,0 +1,123 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import {
fetchK8sEventsForResource,
KubernetesEvent,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
export interface ComponentProps {
clusterIdentifier: string;
resourceKind: string; // "Pod", "Node", "Deployment", etc.
resourceName: string;
namespace?: string | undefined;
}
const KubernetesEventsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [events, setEvents] = useState<Array<KubernetesEvent>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
useEffect(() => {
const fetchEvents: () => Promise<void> = async (): Promise<void> => {
setIsLoading(true);
try {
const result: Array<KubernetesEvent> = await fetchK8sEventsForResource({
clusterIdentifier: props.clusterIdentifier,
resourceKind: props.resourceKind,
resourceName: props.resourceName,
namespace: props.namespace,
});
setEvents(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch events");
}
setIsLoading(false);
};
fetchEvents().catch(() => {});
}, [
props.clusterIdentifier,
props.resourceKind,
props.resourceName,
props.namespace,
]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (events.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No events found for this {props.resourceKind.toLowerCase()} in the last
24 hours.
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Reason
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Message
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.map((event: KubernetesEvent, index: number) => {
const isWarning: boolean = event.type.toLowerCase() === "warning";
return (
<tr key={index} className={isWarning ? "bg-yellow-50" : ""}>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{event.timestamp}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
isWarning
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
>
{event.type}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.reason}
</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-lg">
{event.message}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
export default KubernetesEventsTab;

View File

@@ -0,0 +1,116 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import {
fetchPodLogs,
KubernetesLogEntry,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
export interface ComponentProps {
clusterIdentifier: string;
podName: string;
containerName?: string | undefined;
namespace?: string | undefined;
}
const KubernetesLogsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [logs, setLogs] = useState<Array<KubernetesLogEntry>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
useEffect(() => {
const fetchLogs: () => Promise<void> = async (): Promise<void> => {
setIsLoading(true);
try {
const result: Array<KubernetesLogEntry> = await fetchPodLogs({
clusterIdentifier: props.clusterIdentifier,
podName: props.podName,
containerName: props.containerName,
namespace: props.namespace,
limit: 500,
});
setLogs(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch logs");
}
setIsLoading(false);
};
fetchLogs().catch(() => {});
}, [
props.clusterIdentifier,
props.podName,
props.containerName,
props.namespace,
]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (logs.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No application logs found for this pod in the last 6 hours. Logs will
appear here once the kubernetes-agent&apos;s filelog receiver is
collecting data.
</div>
);
}
const getSeverityColor: (severity: string) => string = (
severity: string,
): string => {
const s: string = severity.toUpperCase();
if (s === "ERROR" || s === "FATAL" || s === "CRITICAL") {
return "text-red-600";
}
if (s === "WARN" || s === "WARNING") {
return "text-yellow-600";
}
if (s === "DEBUG" || s === "TRACE") {
return "text-gray-400";
}
return "text-gray-700";
};
return (
<div className="bg-gray-900 rounded-lg p-4 overflow-auto max-h-[600px] font-mono text-xs">
{logs.map((log: KubernetesLogEntry, index: number) => {
return (
<div key={index} className="flex gap-2 py-0.5 hover:bg-gray-800">
<span className="text-gray-500 whitespace-nowrap flex-shrink-0">
{log.timestamp}
</span>
{log.containerName && (
<span className="text-blue-400 whitespace-nowrap flex-shrink-0">
[{log.containerName}]
</span>
)}
<span
className={`whitespace-nowrap flex-shrink-0 w-12 ${getSeverityColor(log.severity)}`}
>
{log.severity}
</span>
<span className="text-gray-200 whitespace-pre-wrap break-all">
{log.body}
</span>
</div>
);
})}
</div>
);
};
export default KubernetesLogsTab;

View File

@@ -0,0 +1,43 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import MetricView from "../../Components/Metrics/MetricView";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import OneUptimeDate from "Common/Types/Date";
import InBetween from "Common/Types/BaseDatabase/InBetween";
export interface ComponentProps {
queryConfigs: Array<MetricQueryConfigData>;
}
const KubernetesMetricsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6);
const startAndEndDate: InBetween<Date> = new InBetween(startDate, endDate);
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
startAndEndDate: startAndEndDate,
queryConfigs: [],
formulaConfigs: [],
});
return (
<MetricView
data={{
...metricViewData,
queryConfigs: props.queryConfigs,
}}
hideQueryElements={true}
onChange={(data: MetricViewData) => {
setMetricViewData({
...data,
queryConfigs: props.queryConfigs,
formulaConfigs: [],
});
}}
/>
);
};
export default KubernetesMetricsTab;

View File

@@ -0,0 +1,168 @@
import React, { FunctionComponent, ReactElement } from "react";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer";
import { KubernetesCondition } from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
export interface SummaryField {
title: string;
value: string | ReactElement;
}
export interface ComponentProps {
summaryFields: Array<SummaryField>;
labels: Record<string, string>;
annotations: Record<string, string>;
conditions?: Array<KubernetesCondition> | undefined;
ownerReferences?: Array<{ kind: string; name: string }> | undefined;
isLoading: boolean;
emptyMessage?: string | undefined;
}
const KubernetesOverviewTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
if (props.isLoading) {
return <PageLoader isVisible={true} />;
}
if (
props.summaryFields.length === 0 &&
Object.keys(props.labels).length === 0
) {
return (
<div className="text-gray-500 text-sm p-4">
{props.emptyMessage ||
"Resource details not yet available. Ensure the kubernetes-agent Helm chart has resourceSpecs.enabled set to true and wait for the next data pull (up to 5 minutes)."}
</div>
);
}
return (
<div className="space-y-6">
{/* Summary Info Cards */}
{props.summaryFields.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{props.summaryFields.map((field: SummaryField, index: number) => {
return (
<InfoCard key={index} title={field.title} value={field.value} />
);
})}
</div>
)}
{/* Owner References */}
{props.ownerReferences && props.ownerReferences.length > 0 && (
<Card
title="Owner References"
description="Resources that own this object."
>
<div className="space-y-1">
{props.ownerReferences.map(
(ref: { kind: string; name: string }, index: number) => {
return (
<div key={index} className="text-sm">
<span className="font-medium text-gray-700">
{ref.kind}:
</span>{" "}
<span className="text-gray-600">{ref.name}</span>
</div>
);
},
)}
</div>
</Card>
)}
{/* Conditions */}
{props.conditions && props.conditions.length > 0 && (
<Card
title="Conditions"
description="Current status conditions of this resource."
>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Reason
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Message
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Last Transition
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{props.conditions.map(
(condition: KubernetesCondition, index: number) => {
return (
<tr key={index}>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
{condition.type}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
condition.status === "True"
? "bg-green-50 text-green-700"
: condition.status === "False"
? "bg-red-50 text-red-700"
: "bg-gray-50 text-gray-700"
}`}
>
{condition.status}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{condition.reason || "-"}
</td>
<td className="px-4 py-3 text-sm text-gray-600 max-w-md truncate">
{condition.message || "-"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{condition.lastTransitionTime || "-"}
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Labels */}
{Object.keys(props.labels).length > 0 && (
<Card
title="Labels"
description="Key-value labels attached to this resource."
>
<DictionaryOfStringsViewer value={props.labels} />
</Card>
)}
{/* Annotations */}
{Object.keys(props.annotations).length > 0 && (
<Card
title="Annotations"
description="Metadata annotations on this resource."
>
<DictionaryOfStringsViewer value={props.annotations} />
</Card>
)}
</div>
);
};
export default KubernetesOverviewTab;

View File

@@ -0,0 +1,167 @@
import React, { FunctionComponent, ReactElement } from "react";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../../Pages/Kubernetes/Utils/KubernetesResourceUtils";
import Card from "Common/UI/Components/Card/Card";
import Table from "Common/UI/Components/Table/Table";
import FieldType from "Common/UI/Components/Types/FieldType";
import Link from "Common/UI/Components/Link/Link";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Route from "Common/Types/API/Route";
import Column from "Common/UI/Components/Table/Types/Column";
export interface ResourceColumn {
title: string;
key: string;
getValue?: (resource: KubernetesResource) => string;
}
export interface ComponentProps {
resources: Array<KubernetesResource>;
title: string;
description: string;
columns?: Array<ResourceColumn>;
showNamespace?: boolean;
getViewRoute?: (resource: KubernetesResource) => Route;
emptyMessage?: string;
}
const KubernetesResourceTable: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const showNamespace: boolean = props.showNamespace !== false;
const tableColumns: Array<Column<KubernetesResource>> = [
{
title: "Name",
type: FieldType.Element,
key: "name",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span className="font-medium text-gray-900">{resource.name}</span>
);
},
},
];
if (showNamespace) {
tableColumns.push({
title: "Namespace",
type: FieldType.Element,
key: "namespace",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span className="inline-flex px-2 py-0.5 text-xs font-medium rounded bg-blue-50 text-blue-700">
{resource.namespace || "default"}
</span>
);
},
});
}
if (props.columns) {
for (const col of props.columns) {
tableColumns.push({
title: col.title,
type: FieldType.Element,
key: col.key as keyof KubernetesResource,
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
const value: string = col.getValue
? col.getValue(resource)
: resource.additionalAttributes[col.key] || "";
return <span>{value}</span>;
},
});
}
}
tableColumns.push(
{
title: "CPU",
type: FieldType.Element,
key: "cpuUtilization",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
resource.cpuUtilization !== null && resource.cpuUtilization > 80
? "bg-red-50 text-red-700"
: resource.cpuUtilization !== null &&
resource.cpuUtilization > 60
? "bg-yellow-50 text-yellow-700"
: "bg-green-50 text-green-700"
}`}
>
{KubernetesResourceUtils.formatCpuValue(resource.cpuUtilization)}
</span>
);
},
},
{
title: "Memory",
type: FieldType.Element,
key: "memoryUsageBytes",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span>
{KubernetesResourceUtils.formatMemoryValue(
resource.memoryUsageBytes,
)}
</span>
);
},
},
);
if (props.getViewRoute) {
tableColumns.push({
title: "Actions",
type: FieldType.Element,
key: "name",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<Link
to={props.getViewRoute!(resource)}
className="text-indigo-600 hover:text-indigo-900 font-medium"
>
View
</Link>
);
},
});
}
return (
<Card title={props.title} description={props.description}>
<Table<KubernetesResource>
id={`kubernetes-${props.title.toLowerCase().replace(/\s+/g, "-")}-table`}
columns={tableColumns}
data={props.resources}
singularLabel={props.title}
pluralLabel={props.title}
isLoading={false}
error=""
disablePagination={true}
currentPageNumber={1}
totalItemsCount={props.resources.length}
itemsOnPage={props.resources.length}
onNavigateToPage={() => {}}
sortBy={null}
sortOrder={SortOrder.Ascending}
onSortChanged={() => {}}
noItemsMessage={
props.emptyMessage ||
"No resources found. Resources will appear here once the kubernetes-agent is sending data."
}
/>
</Card>
);
};
export default KubernetesResourceTable;

View File

@@ -413,10 +413,32 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
}
try {
/*
* When live polling, recompute the time range so the query window
* slides forward to "now" and new logs become visible.
*/
let query: Query<Log> = filterOptions;
if (
skipLoadingState &&
isLiveEnabled &&
timeRange.range !== TimeRange.CUSTOM
) {
const freshRange: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange);
query = {
...filterOptions,
time: new InBetween<Date>(
freshRange.startValue,
freshRange.endValue,
),
};
}
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>({
modelType: Log,
query: filterOptions,
query: query,
limit: pageSize,
skip: (page - 1) * pageSize,
select: select,
@@ -452,7 +474,16 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
}
}
},
[filterOptions, page, pageSize, select, sortField, sortOrder],
[
filterOptions,
isLiveEnabled,
page,
pageSize,
select,
sortField,
sortOrder,
timeRange,
],
);
// --- Fetch histogram ---

View File

@@ -144,17 +144,17 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
iconColor: "indigo",
category: "Observability",
},
// {
// title: "Kubernetes",
// description: "Monitor Kubernetes clusters.",
// route: RouteUtil.populateRouteParams(
// RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route,
// ),
// activeRoute: RouteMap[PageMap.KUBERNETES_CLUSTERS],
// icon: IconProp.Kubernetes,
// iconColor: "blue",
// category: "Observability",
// },
{
title: "Kubernetes",
description: "Monitor Kubernetes clusters.",
route: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route,
),
activeRoute: RouteMap[PageMap.KUBERNETES_CLUSTERS],
icon: IconProp.Kubernetes,
iconColor: "blue",
category: "Observability",
},
// Automation & Analytics
{
title: "Dashboards",

View File

@@ -0,0 +1,408 @@
import Log from "Common/Models/AnalyticsModels/Log";
import AnalyticsModelAPI, {
ListResult,
} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
import ProjectUtil from "Common/UI/Utils/Project";
import OneUptimeDate from "Common/Types/Date";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import { JSONObject } from "Common/Types/JSON";
import {
extractObjectFromLogBody,
getKvStringValue,
getKvValue,
KubernetesPodObject,
KubernetesNodeObject,
KubernetesDeploymentObject,
KubernetesStatefulSetObject,
KubernetesDaemonSetObject,
KubernetesJobObject,
KubernetesCronJobObject,
KubernetesNamespaceObject,
parsePodObject,
parseNodeObject,
parseDeploymentObject,
parseStatefulSetObject,
parseDaemonSetObject,
parseJobObject,
parseCronJobObject,
parseNamespaceObject,
} from "./KubernetesObjectParser";
export type KubernetesObjectType =
| KubernetesPodObject
| KubernetesNodeObject
| KubernetesDeploymentObject
| KubernetesStatefulSetObject
| KubernetesDaemonSetObject
| KubernetesJobObject
| KubernetesCronJobObject
| KubernetesNamespaceObject;
export interface FetchK8sObjectOptions {
clusterIdentifier: string;
resourceType: string; // "pods", "nodes", "deployments", etc.
resourceName: string;
namespace?: string | undefined; // Not needed for cluster-scoped resources (nodes, namespaces)
}
type ParserFunction = (kvList: JSONObject) => KubernetesObjectType | null;
function getParser(resourceType: string): ParserFunction | null {
const parsers: Record<string, ParserFunction> = {
pods: parsePodObject,
nodes: parseNodeObject,
deployments: parseDeploymentObject,
statefulsets: parseStatefulSetObject,
daemonsets: parseDaemonSetObject,
jobs: parseJobObject,
cronjobs: parseCronJobObject,
namespaces: parseNamespaceObject,
};
return parsers[resourceType] || null;
}
/**
* Fetch the latest K8s resource object from the Log table.
* The k8sobjects pull mode stores full K8s API objects as log entries.
*/
export async function fetchLatestK8sObject<T extends KubernetesObjectType>(
options: FetchK8sObjectOptions,
): Promise<T | null> {
const parser: ParserFunction | null = getParser(options.resourceType);
if (!parser) {
return null;
}
const projectId: string | undefined =
ProjectUtil.getCurrentProjectId()?.toString();
if (!projectId) {
return null;
}
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const queryOptions: any = {
modelType: Log,
query: {
projectId: projectId,
time: new InBetween<Date>(startDate, endDate),
attributes: {
"logAttributes.event.domain": "k8s",
"logAttributes.k8s.resource.name": options.resourceType,
},
},
limit: 500, // Get enough logs to find the resource
skip: 0,
select: {
time: true,
body: true,
attributes: true,
},
sort: {
time: SortOrder.Descending,
},
requestOptions: {},
};
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>(queryOptions);
// Parse each log body and find the matching resource
for (const log of listResult.data) {
const attrs: JSONObject = log.attributes || {};
// Filter to this cluster
if (
attrs["resource.k8s.cluster.name"] !== options.clusterIdentifier &&
attrs["k8s.cluster.name"] !== options.clusterIdentifier
) {
continue;
}
if (typeof log.body !== "string") {
continue;
}
const objectKvList: JSONObject | null = extractObjectFromLogBody(
log.body,
);
if (!objectKvList) {
continue;
}
// Check if this is the resource we're looking for
const metadataKv: string | JSONObject | null = getKvValue(
objectKvList,
"metadata",
);
if (!metadataKv || typeof metadataKv === "string") {
continue;
}
const name: string = getKvStringValue(metadataKv, "name");
const namespace: string = getKvStringValue(metadataKv, "namespace");
if (name !== options.resourceName) {
continue;
}
// For namespaced resources, also match namespace
if (options.namespace && namespace && namespace !== options.namespace) {
continue;
}
const parsed: KubernetesObjectType | null = parser(objectKvList);
if (parsed) {
return parsed as T;
}
}
return null;
} catch {
return null;
}
}
/**
* Fetch K8s events related to a specific resource.
*/
export interface KubernetesEvent {
timestamp: string;
type: string;
reason: string;
objectKind: string;
objectName: string;
namespace: string;
message: string;
}
export async function fetchK8sEventsForResource(options: {
clusterIdentifier: string;
resourceKind: string; // "Pod", "Node", "Deployment", etc.
resourceName: string;
namespace?: string | undefined;
}): Promise<Array<KubernetesEvent>> {
const projectId: string | undefined =
ProjectUtil.getCurrentProjectId()?.toString();
if (!projectId) {
return [];
}
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eventsQueryOptions: any = {
modelType: Log,
query: {
projectId: projectId,
time: new InBetween<Date>(startDate, endDate),
attributes: {
"logAttributes.event.domain": "k8s",
"logAttributes.k8s.resource.name": "events",
},
},
limit: 500,
skip: 0,
select: {
time: true,
body: true,
attributes: true,
},
sort: {
time: SortOrder.Descending,
},
requestOptions: {},
};
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>(eventsQueryOptions);
const events: Array<KubernetesEvent> = [];
for (const log of listResult.data) {
const attrs: JSONObject = log.attributes || {};
// Filter to this cluster
if (
attrs["resource.k8s.cluster.name"] !== options.clusterIdentifier &&
attrs["k8s.cluster.name"] !== options.clusterIdentifier
) {
continue;
}
if (typeof log.body !== "string") {
continue;
}
let bodyObj: JSONObject | null = null;
try {
bodyObj = JSON.parse(log.body) as JSONObject;
} catch {
continue;
}
const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as
| JSONObject
| undefined;
if (!topKvList) {
continue;
}
// Get the "object" which is the actual k8s Event
const objectVal: string | JSONObject | null = getKvValue(
topKvList,
"object",
);
if (!objectVal || typeof objectVal === "string") {
continue;
}
const objectKvList: JSONObject = objectVal;
// Get event details
const eventType: string = getKvStringValue(objectKvList, "type") || "";
const reason: string = getKvStringValue(objectKvList, "reason") || "";
const note: string = getKvStringValue(objectKvList, "note") || "";
// Get regarding object
const regardingKind: string =
getKvStringValue(
getKvValue(objectKvList, "regarding") as JSONObject | undefined,
"kind",
) || "";
const regardingName: string =
getKvStringValue(
getKvValue(objectKvList, "regarding") as JSONObject | undefined,
"name",
) || "";
const regardingNamespace: string =
getKvStringValue(
getKvValue(objectKvList, "regarding") as JSONObject | undefined,
"namespace",
) || "";
// Filter to events for this specific resource
if (
regardingKind.toLowerCase() !== options.resourceKind.toLowerCase() ||
regardingName !== options.resourceName
) {
continue;
}
if (
options.namespace &&
regardingNamespace &&
regardingNamespace !== options.namespace
) {
continue;
}
events.push({
timestamp: log.time
? OneUptimeDate.getDateAsLocalFormattedString(log.time)
: "",
type: eventType || "Unknown",
reason: reason || "Unknown",
objectKind: regardingKind || "Unknown",
objectName: regardingName || "Unknown",
namespace: regardingNamespace || "default",
message: note || "",
});
}
return events;
} catch {
return [];
}
}
/**
* Fetch application logs for a pod/container from the Log table.
* These come from the filelog receiver (not k8sobjects).
*/
export interface KubernetesLogEntry {
timestamp: string;
body: string;
severity: string;
containerName: string;
}
export async function fetchPodLogs(options: {
clusterIdentifier: string;
podName: string;
containerName?: string | undefined;
namespace?: string | undefined;
limit?: number | undefined;
}): Promise<Array<KubernetesLogEntry>> {
const projectId: string | undefined =
ProjectUtil.getCurrentProjectId()?.toString();
if (!projectId) {
return [];
}
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6);
// Build attribute filters for filelog data
const attributeFilters: Record<string, string> = {
"resource.k8s.cluster.name": options.clusterIdentifier,
"resource.k8s.pod.name": options.podName,
};
if (options.containerName) {
attributeFilters["resource.k8s.container.name"] = options.containerName;
}
if (options.namespace) {
attributeFilters["resource.k8s.namespace.name"] = options.namespace;
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const logsQueryOptions: any = {
modelType: Log,
query: {
projectId: projectId,
time: new InBetween<Date>(startDate, endDate),
attributes: attributeFilters,
},
limit: options.limit || 200,
skip: 0,
select: {
time: true,
body: true,
severityText: true,
attributes: true,
},
sort: {
time: SortOrder.Descending,
},
requestOptions: {},
};
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>(logsQueryOptions);
return listResult.data
.filter((log: Log) => {
// Exclude k8s event logs — only application logs
const attrs: JSONObject = log.attributes || {};
return attrs["logAttributes.event.domain"] !== "k8s";
})
.map((log: Log) => {
const attrs: JSONObject = log.attributes || {};
return {
timestamp: log.time
? OneUptimeDate.getDateAsLocalFormattedString(log.time)
: "",
body: typeof log.body === "string" ? log.body : "",
severity: log.severityText || "INFO",
containerName: (attrs["resource.k8s.container.name"] as string) || "",
};
});
} catch {
return [];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,213 @@
import Metric from "Common/Models/AnalyticsModels/Metric";
import AnalyticsModelAPI from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI";
import ProjectUtil from "Common/UI/Utils/Project";
import OneUptimeDate from "Common/Types/Date";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
import AggregatedResult from "Common/Types/BaseDatabase/AggregatedResult";
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import Dictionary from "Common/Types/Dictionary";
export interface KubernetesResource {
name: string;
namespace: string;
cpuUtilization: number | null;
memoryUsageBytes: number | null;
additionalAttributes: Record<string, string>;
}
export interface FetchResourceListOptions {
clusterIdentifier: string;
metricName: string;
resourceNameAttribute: string;
namespaceAttribute?: string;
additionalAttributes?: Array<string>;
filterAttributes?: Dictionary<string>;
hoursBack?: number;
}
export default class KubernetesResourceUtils {
public static async fetchResourceList(
options: FetchResourceListOptions,
): Promise<Array<KubernetesResource>> {
const {
clusterIdentifier,
metricName,
resourceNameAttribute,
namespaceAttribute = "resource.k8s.namespace.name",
additionalAttributes = [],
filterAttributes = {},
hoursBack = 24,
} = options;
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -hoursBack);
const cpuResult: AggregatedResult = await AnalyticsModelAPI.aggregate({
modelType: Metric,
aggregateBy: {
query: {
projectId: ProjectUtil.getCurrentProjectId()!,
time: new InBetween(startDate, endDate),
name: metricName,
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
...filterAttributes,
} as Dictionary<string | number | boolean>,
},
aggregationType: MetricsAggregationType.Avg,
aggregateColumnName: "value",
aggregationTimestampColumnName: "time",
startTimestamp: startDate,
endTimestamp: endDate,
limit: LIMIT_PER_PROJECT,
skip: 0,
groupBy: {
attributes: true,
},
},
});
const resourceMap: Map<string, KubernetesResource> = new Map();
for (const dataPoint of cpuResult.data) {
const attributes: Record<string, unknown> =
(dataPoint["attributes"] as Record<string, unknown>) || {};
const resourceName: string =
(attributes[resourceNameAttribute] as string) || "";
if (!resourceName) {
continue;
}
const namespace: string =
(attributes[namespaceAttribute] as string) || "";
const key: string = `${namespace}/${resourceName}`;
if (!resourceMap.has(key)) {
const additionalAttrs: Record<string, string> = {};
for (const attr of additionalAttributes) {
additionalAttrs[attr] = (attributes[attr] as string) || "";
}
resourceMap.set(key, {
name: resourceName,
namespace: namespace,
cpuUtilization: dataPoint.value ?? null,
memoryUsageBytes: null,
additionalAttributes: additionalAttrs,
});
}
}
return Array.from(resourceMap.values()).sort(
(a: KubernetesResource, b: KubernetesResource) => {
const nsCompare: number = a.namespace.localeCompare(b.namespace);
if (nsCompare !== 0) {
return nsCompare;
}
return a.name.localeCompare(b.name);
},
);
}
public static async fetchResourceListWithMemory(
options: FetchResourceListOptions & { memoryMetricName: string },
): Promise<Array<KubernetesResource>> {
const resources: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceList(options);
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(
endDate,
-(options.hoursBack || 1),
);
try {
const memoryResult: AggregatedResult = await AnalyticsModelAPI.aggregate({
modelType: Metric,
aggregateBy: {
query: {
projectId: ProjectUtil.getCurrentProjectId()!,
time: new InBetween(startDate, endDate),
name: options.memoryMetricName,
attributes: {
"resource.k8s.cluster.name": options.clusterIdentifier,
...(options.filterAttributes || {}),
} as Dictionary<string | number | boolean>,
},
aggregationType: MetricsAggregationType.Avg,
aggregateColumnName: "value",
aggregationTimestampColumnName: "time",
startTimestamp: startDate,
endTimestamp: endDate,
limit: LIMIT_PER_PROJECT,
skip: 0,
groupBy: {
attributes: true,
},
},
});
const memoryMap: Map<string, number> = new Map();
for (const dataPoint of memoryResult.data) {
const attributes: Record<string, unknown> =
(dataPoint["attributes"] as Record<string, unknown>) || {};
const resourceName: string =
(attributes[options.resourceNameAttribute] as string) || "";
const namespace: string =
(attributes[
options.namespaceAttribute || "resource.k8s.namespace.name"
] as string) || "";
const key: string = `${namespace}/${resourceName}`;
if (resourceName && !memoryMap.has(key)) {
memoryMap.set(key, dataPoint.value ?? 0);
}
}
for (const resource of resources) {
const key: string = `${resource.namespace}/${resource.name}`;
const memValue: number | undefined = memoryMap.get(key);
if (memValue !== undefined) {
resource.memoryUsageBytes = memValue;
}
}
} catch {
// Memory data is optional, don't fail if not available
}
return resources;
}
public static formatCpuValue(value: number | null): string {
if (value === null || value === undefined) {
return "N/A";
}
return `${value.toFixed(1)}%`;
}
public static formatMemoryValue(bytes: number | null): string {
if (bytes === null || bytes === undefined) {
return "N/A";
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
}

View File

@@ -0,0 +1,194 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import KubernetesLogsTab from "../../../Components/Kubernetes/KubernetesLogsTab";
const KubernetesClusterContainerDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const containerName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const getSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const name: string =
(attributes["resource.k8s.container.name"] as string) ||
"Unknown Container";
return { title: name };
};
const cpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "container_cpu",
title: "Container CPU Utilization",
description: `CPU utilization for container ${containerName}`,
legend: "CPU",
legendUnit: "%",
},
metricQueryData: {
filterData: {
metricName: "container.cpu.utilization",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.container.name": containerName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
const memoryQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "container_memory",
title: "Container Memory Usage",
description: `Memory usage for container ${containerName}`,
legend: "Memory",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "container.memory.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.container.name": containerName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<div className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<InfoCard
title="Container Name"
value={containerName || "Unknown"}
/>
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
),
},
{
name: "Logs",
children: (
<Card
title="Container Logs"
description="Logs for this container from the last 6 hours."
>
<KubernetesLogsTab
clusterIdentifier={clusterIdentifier}
podName=""
containerName={containerName}
/>
</Card>
),
},
{
name: "Metrics",
children: (
<Card
title={`Container Metrics: ${containerName}`}
description="CPU and memory usage for this container over the last 6 hours."
>
<KubernetesMetricsTab queryConfigs={[cpuQuery, memoryQuery]} />
</Card>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Container" value={containerName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
};
export default KubernetesClusterContainerDetail;

View File

@@ -0,0 +1,107 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterContainers: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const containerList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "container.cpu.utilization",
memoryMetricName: "container.memory.usage",
resourceNameAttribute: "resource.k8s.container.name",
additionalAttributes: ["resource.k8s.pod.name"],
});
setResources(containerList);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<Fragment>
<KubernetesResourceTable
title="Containers"
description="All containers running in this cluster."
resources={resources}
columns={[
{
title: "Pod",
key: "resource.k8s.pod.name",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
);
};
export default KubernetesClusterContainers;

View File

@@ -0,0 +1,270 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesCronJobObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterCronJobDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const cronJobName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [cronJobObject, setCronJobObject] =
useState<KubernetesCronJobObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
// Fetch the K8s cronjob object for overview tab
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchCronJobObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesCronJobObject | null =
await fetchLatestK8sObject<KubernetesCronJobObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "cronjobs",
resourceName: cronJobName,
});
setCronJobObject(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchCronJobObject().catch(() => {});
}, [cluster?.clusterIdentifier, cronJobName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const getSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const podName: string =
(attributes["resource.k8s.pod.name"] as string) || "Unknown Pod";
return { title: podName };
};
const cpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "cronjob_cpu",
title: "Pod CPU Utilization",
description: `CPU utilization for pods in cronjob ${cronJobName}`,
legend: "CPU",
legendUnit: "%",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.cpu.utilization",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.cronjob.name": cronJobName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
const memoryQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "cronjob_memory",
title: "Pod Memory Usage",
description: `Memory usage for pods in cronjob ${cronJobName}`,
legend: "Memory",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.memory.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.cronjob.name": cronJobName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
// Build overview summary fields from cronjob object
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "CronJob Name", value: cronJobName },
{ title: "Cluster", value: clusterIdentifier },
];
if (cronJobObject) {
summaryFields.push(
{
title: "Namespace",
value: cronJobObject.metadata.namespace || "default",
},
{
title: "Schedule",
value: cronJobObject.spec.schedule || "N/A",
},
{
title: "Suspend",
value: cronJobObject.spec.suspend ? "Yes" : "No",
},
{
title: "Concurrency Policy",
value: cronJobObject.spec.concurrencyPolicy || "N/A",
},
{
title: "Successful Jobs History Limit",
value: String(cronJobObject.spec.successfulJobsHistoryLimit ?? "N/A"),
},
{
title: "Failed Jobs History Limit",
value: String(cronJobObject.spec.failedJobsHistoryLimit ?? "N/A"),
},
{
title: "Last Schedule Time",
value: cronJobObject.status.lastScheduleTime || "N/A",
},
{
title: "Active Jobs",
value: String(cronJobObject.status.activeCount ?? 0),
},
{
title: "Created",
value: cronJobObject.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={cronJobObject?.metadata.labels || {}}
annotations={cronJobObject?.metadata.annotations || {}}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="CronJob Events"
description="Kubernetes events for this cronjob in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="CronJob"
resourceName={cronJobName}
namespace={cronJobObject?.metadata.namespace}
/>
</Card>
),
},
{
name: "Metrics",
children: (
<Card
title={`CronJob Metrics: ${cronJobName}`}
description="CPU and memory usage for pods in this cronjob over the last 6 hours."
>
<KubernetesMetricsTab queryConfigs={[cpuQuery, memoryQuery]} />
</Card>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="CronJob Name" value={cronJobName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
};
export default KubernetesClusterCronJobDetail;

View File

@@ -0,0 +1,100 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterCronJobs: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const cronjobList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.cronjob.name",
});
setResources(cronjobList);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<Fragment>
<KubernetesResourceTable
title="CronJobs"
description="All cron jobs in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
);
};
export default KubernetesClusterCronJobs;

View File

@@ -0,0 +1,262 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesDaemonSetObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterDaemonSetDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const daemonSetName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [objectData, setObjectData] =
useState<KubernetesDaemonSetObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
// Fetch the K8s daemonset object for overview tab
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesDaemonSetObject | null =
await fetchLatestK8sObject<KubernetesDaemonSetObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "daemonsets",
resourceName: daemonSetName,
});
setObjectData(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchObject().catch(() => {});
}, [cluster?.clusterIdentifier, daemonSetName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const getSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const podName: string =
(attributes["resource.k8s.pod.name"] as string) || "Unknown Pod";
return { title: podName };
};
const cpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "daemonset_cpu",
title: "Pod CPU Utilization",
description: `CPU utilization for pods in daemonset ${daemonSetName}`,
legend: "CPU",
legendUnit: "%",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.cpu.utilization",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.daemonset.name": daemonSetName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
const memoryQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "daemonset_memory",
title: "Pod Memory Usage",
description: `Memory usage for pods in daemonset ${daemonSetName}`,
legend: "Memory",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.memory.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.daemonset.name": daemonSetName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
// Build overview summary fields from daemonset object
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "Name", value: daemonSetName },
{ title: "Cluster", value: clusterIdentifier },
];
if (objectData) {
summaryFields.push(
{
title: "Namespace",
value: objectData.metadata.namespace || "default",
},
{
title: "Desired Scheduled",
value: String(objectData.status.desiredNumberScheduled ?? "N/A"),
},
{
title: "Current Scheduled",
value: String(objectData.status.currentNumberScheduled ?? "N/A"),
},
{
title: "Number Ready",
value: String(objectData.status.numberReady ?? "N/A"),
},
{
title: "Number Available",
value: String(objectData.status.numberAvailable ?? "N/A"),
},
{
title: "Update Strategy",
value: objectData.spec.updateStrategy || "N/A",
},
{
title: "Created",
value: objectData.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={objectData?.metadata.labels || {}}
annotations={objectData?.metadata.annotations || {}}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="DaemonSet Events"
description="Kubernetes events for this daemonset in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="DaemonSet"
resourceName={daemonSetName}
namespace={objectData?.metadata.namespace}
/>
</Card>
),
},
{
name: "Metrics",
children: (
<Card
title={`DaemonSet Metrics: ${daemonSetName}`}
description="CPU and memory usage for pods in this daemonset over the last 6 hours."
>
<KubernetesMetricsTab queryConfigs={[cpuQuery, memoryQuery]} />
</Card>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="DaemonSet" value={daemonSetName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
};
export default KubernetesClusterDaemonSetDetail;

View File

@@ -0,0 +1,100 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterDaemonSets: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const daemonsetList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.daemonset.name",
});
setResources(daemonsetList);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<Fragment>
<KubernetesResourceTable
title="DaemonSets"
description="All daemonsets running in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
);
};
export default KubernetesClusterDaemonSets;

View File

@@ -0,0 +1,259 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesDeploymentObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterDeploymentDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const deploymentName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [objectData, setObjectData] =
useState<KubernetesDeploymentObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
// Fetch the K8s deployment object for overview tab
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesDeploymentObject | null =
await fetchLatestK8sObject<KubernetesDeploymentObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "deployments",
resourceName: deploymentName,
});
setObjectData(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchObject().catch(() => {});
}, [cluster?.clusterIdentifier, deploymentName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const getSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const podName: string =
(attributes["resource.k8s.pod.name"] as string) || "Unknown Pod";
return { title: podName };
};
const cpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "deployment_cpu",
title: "Pod CPU Utilization",
description: `CPU utilization for pods in deployment ${deploymentName}`,
legend: "CPU",
legendUnit: "%",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.cpu.utilization",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.deployment.name": deploymentName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
const memoryQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "deployment_memory",
title: "Pod Memory Usage",
description: `Memory usage for pods in deployment ${deploymentName}`,
legend: "Memory",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.memory.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.deployment.name": deploymentName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
// Build overview summary fields from deployment object
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "Name", value: deploymentName },
{ title: "Cluster", value: clusterIdentifier },
];
if (objectData) {
summaryFields.push(
{
title: "Namespace",
value: objectData.metadata.namespace || "default",
},
{
title: "Replicas",
value: String(objectData.spec.replicas ?? "N/A"),
},
{
title: "Ready Replicas",
value: String(objectData.status.readyReplicas ?? "N/A"),
},
{
title: "Available Replicas",
value: String(objectData.status.availableReplicas ?? "N/A"),
},
{
title: "Strategy",
value: objectData.spec.strategy || "N/A",
},
{
title: "Created",
value: objectData.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={objectData?.metadata.labels || {}}
annotations={objectData?.metadata.annotations || {}}
conditions={objectData?.status.conditions}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="Deployment Events"
description="Kubernetes events for this deployment in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="Deployment"
resourceName={deploymentName}
namespace={objectData?.metadata.namespace}
/>
</Card>
),
},
{
name: "Metrics",
children: (
<Card
title={`Deployment Metrics: ${deploymentName}`}
description="CPU and memory usage for pods in this deployment over the last 6 hours."
>
<KubernetesMetricsTab queryConfigs={[cpuQuery, memoryQuery]} />
</Card>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Deployment" value={deploymentName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
};
export default KubernetesClusterDeploymentDetail;

View File

@@ -0,0 +1,102 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterDeployments: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const deploymentList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.deployment.name",
});
setResources(deploymentList);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<Fragment>
<KubernetesResourceTable
title="Deployments"
description="All deployments running in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
);
};
export default KubernetesClusterDeployments;

View File

@@ -24,16 +24,8 @@ import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import { JSONObject } from "Common/Types/JSON";
import InBetween from "Common/Types/BaseDatabase/InBetween";
interface KubernetesEvent {
timestamp: string;
type: string;
reason: string;
objectKind: string;
objectName: string;
namespace: string;
message: string;
}
import { getKvValue, getKvStringValue } from "../Utils/KubernetesObjectParser";
import { KubernetesEvent } from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterEvents: FunctionComponent<
PageComponentProps
@@ -65,13 +57,18 @@ const KubernetesClusterEvents: FunctionComponent<
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24);
const listResult: ListResult<Log> = await AnalyticsModelAPI.getList<Log>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eventsQueryOptions: any = {
modelType: Log,
query: {
projectId: ProjectUtil.getCurrentProjectId()!.toString(),
time: new InBetween<Date>(startDate, endDate),
attributes: {
"logAttributes.event.domain": "k8s",
"logAttributes.k8s.resource.name": "events",
},
},
limit: 200,
limit: 500,
skip: 0,
select: {
time: true,
@@ -83,76 +80,9 @@ const KubernetesClusterEvents: FunctionComponent<
time: SortOrder.Descending,
},
requestOptions: {},
});
// Helper to extract a string value from OTLP kvlistValue
const getKvValue: (
kvList: JSONObject | undefined,
key: string,
) => string = (kvList: JSONObject | undefined, key: string): string => {
if (!kvList) {
return "";
}
const values: Array<JSONObject> | undefined = (kvList as JSONObject)[
"values"
] as Array<JSONObject> | undefined;
if (!values) {
return "";
}
for (const entry of values) {
if (entry["key"] === key) {
const val: JSONObject | undefined = entry["value"] as
| JSONObject
| undefined;
if (!val) {
return "";
}
if (val["stringValue"]) {
return val["stringValue"] as string;
}
if (val["intValue"]) {
return String(val["intValue"]);
}
// Nested kvlist (e.g., regarding, metadata)
if (val["kvlistValue"]) {
return val["kvlistValue"] as unknown as string;
}
}
}
return "";
};
// Helper to get nested kvlist value
const getNestedKvValue: (
kvList: JSONObject | undefined,
parentKey: string,
childKey: string,
) => string = (
kvList: JSONObject | undefined,
parentKey: string,
childKey: string,
): string => {
if (!kvList) {
return "";
}
const values: Array<JSONObject> | undefined = (kvList as JSONObject)[
"values"
] as Array<JSONObject> | undefined;
if (!values) {
return "";
}
for (const entry of values) {
if (entry["key"] === parentKey) {
const val: JSONObject | undefined = entry["value"] as
| JSONObject
| undefined;
if (val && val["kvlistValue"]) {
return getKvValue(val["kvlistValue"] as JSONObject, childKey);
}
}
}
return "";
};
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>(eventsQueryOptions);
const k8sEvents: Array<KubernetesEvent> = [];
@@ -167,11 +97,6 @@ const KubernetesClusterEvents: FunctionComponent<
continue;
}
// Only process k8s event logs (from k8sobjects receiver)
if (attrs["logAttributes.event.domain"] !== "k8s") {
continue;
}
// Parse the body which is OTLP kvlistValue JSON
let bodyObj: JSONObject | null = null;
try {
@@ -186,7 +111,6 @@ const KubernetesClusterEvents: FunctionComponent<
continue;
}
// The body has a top-level kvlistValue with "type" (ADDED/MODIFIED) and "object" keys
const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as
| JSONObject
| undefined;
@@ -195,25 +119,46 @@ const KubernetesClusterEvents: FunctionComponent<
}
// Get the "object" which is the actual k8s Event
const objectKvListRaw: string = getKvValue(topKvList, "object");
if (!objectKvListRaw || typeof objectKvListRaw === "string") {
const objectVal: string | JSONObject | null = getKvValue(
topKvList,
"object",
);
if (!objectVal || typeof objectVal === "string") {
continue;
}
const objectKvList: JSONObject =
objectKvListRaw as unknown as JSONObject;
const objectKvList: JSONObject = objectVal;
const eventType: string = getKvValue(objectKvList, "type") || "";
const reason: string = getKvValue(objectKvList, "reason") || "";
const note: string = getKvValue(objectKvList, "note") || "";
const eventType: string = getKvStringValue(objectKvList, "type") || "";
const reason: string = getKvStringValue(objectKvList, "reason") || "";
const note: string = getKvStringValue(objectKvList, "note") || "";
// Get regarding object details using shared parser
const regardingKv: string | JSONObject | null = getKvValue(
objectKvList,
"regarding",
);
const regardingObj: JSONObject | undefined =
regardingKv && typeof regardingKv !== "string"
? regardingKv
: undefined;
const objectKind: string = regardingObj
? getKvStringValue(regardingObj, "kind")
: "";
const objectName: string = regardingObj
? getKvStringValue(regardingObj, "name")
: "";
const metadataKv: string | JSONObject | null = getKvValue(
objectKvList,
"metadata",
);
const metadataObj: JSONObject | undefined =
metadataKv && typeof metadataKv !== "string" ? metadataKv : undefined;
// Get object details from "regarding" sub-object
const objectKind: string =
getNestedKvValue(objectKvList, "regarding", "kind") || "";
const objectName: string =
getNestedKvValue(objectKvList, "regarding", "name") || "";
const namespace: string =
getNestedKvValue(objectKvList, "regarding", "namespace") ||
getNestedKvValue(objectKvList, "metadata", "namespace") ||
(regardingObj ? getKvStringValue(regardingObj, "namespace") : "") ||
(metadataObj ? getKvStringValue(metadataObj, "namespace") : "") ||
"";
if (eventType || reason) {

View File

@@ -6,6 +6,10 @@ import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import FieldType from "Common/UI/Components/Types/FieldType";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import Card from "Common/UI/Components/Card/Card";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import React, {
Fragment,
FunctionComponent,
@@ -19,6 +23,12 @@ import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
interface ResourceLink {
title: string;
description: string;
pageMap: PageMap;
}
const KubernetesClusterOverview: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
@@ -75,6 +85,57 @@ const KubernetesClusterOverview: FunctionComponent<
? "text-green-600"
: "text-red-600";
const workloadLinks: Array<ResourceLink> = [
{
title: "Namespaces",
description: "View all namespaces",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES,
},
{
title: "Pods",
description: "View all pods",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PODS,
},
{
title: "Deployments",
description: "View all deployments",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS,
},
{
title: "StatefulSets",
description: "View all statefulsets",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS,
},
{
title: "DaemonSets",
description: "View all daemonsets",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS,
},
{
title: "Jobs",
description: "View all jobs",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_JOBS,
},
{
title: "CronJobs",
description: "View all cron jobs",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS,
},
];
const infraLinks: Array<ResourceLink> = [
{
title: "Nodes",
description: "View all nodes",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_NODES,
},
{
title: "Containers",
description: "View all containers",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS,
},
];
return (
<Fragment>
{/* Summary Cards */}
@@ -115,6 +176,66 @@ const KubernetesClusterOverview: FunctionComponent<
/>
</div>
{/* Quick Navigation - Workloads */}
<Card
title="Workloads"
description="Explore workload resources in this cluster."
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
{workloadLinks.map((link: ResourceLink) => {
return (
<a
key={link.title}
href={RouteUtil.populateRouteParams(
RouteMap[link.pageMap] as Route,
{ modelId: modelId },
).toString()}
className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-indigo-300 hover:bg-indigo-50 transition-colors group"
>
<div>
<div className="font-medium text-gray-900 group-hover:text-indigo-700">
{link.title}
</div>
<div className="text-xs text-gray-500">
{link.description}
</div>
</div>
</a>
);
})}
</div>
</Card>
{/* Quick Navigation - Infrastructure */}
<Card
title="Infrastructure"
description="Explore infrastructure resources in this cluster."
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
{infraLinks.map((link: ResourceLink) => {
return (
<a
key={link.title}
href={RouteUtil.populateRouteParams(
RouteMap[link.pageMap] as Route,
{ modelId: modelId },
).toString()}
className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-indigo-300 hover:bg-indigo-50 transition-colors group"
>
<div>
<div className="font-medium text-gray-900 group-hover:text-indigo-700">
{link.title}
</div>
<div className="text-xs text-gray-500">
{link.description}
</div>
</div>
</a>
);
})}
</div>
</Card>
{/* Cluster Details */}
<CardModelDetail<KubernetesCluster>
name="Cluster Details"

View File

@@ -0,0 +1,274 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesJobObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterJobDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const jobName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [jobObject, setJobObject] = useState<KubernetesJobObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
// Fetch the K8s job object for overview tab
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchJobObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesJobObject | null =
await fetchLatestK8sObject<KubernetesJobObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "jobs",
resourceName: jobName,
});
setJobObject(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchJobObject().catch(() => {});
}, [cluster?.clusterIdentifier, jobName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const getSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const podName: string =
(attributes["resource.k8s.pod.name"] as string) || "Unknown Pod";
return { title: podName };
};
const cpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "job_cpu",
title: "Pod CPU Utilization",
description: `CPU utilization for pods in job ${jobName}`,
legend: "CPU",
legendUnit: "%",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.cpu.utilization",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.job.name": jobName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
const memoryQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "job_memory",
title: "Pod Memory Usage",
description: `Memory usage for pods in job ${jobName}`,
legend: "Memory",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.memory.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.job.name": jobName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
// Build overview summary fields from job object
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "Job Name", value: jobName },
{ title: "Cluster", value: clusterIdentifier },
];
if (jobObject) {
summaryFields.push(
{
title: "Namespace",
value: jobObject.metadata.namespace || "default",
},
{
title: "Completions",
value: String(jobObject.spec.completions ?? "N/A"),
},
{
title: "Parallelism",
value: String(jobObject.spec.parallelism ?? "N/A"),
},
{
title: "Backoff Limit",
value: String(jobObject.spec.backoffLimit ?? "N/A"),
},
{
title: "Active",
value: String(jobObject.status.active ?? 0),
},
{
title: "Succeeded",
value: String(jobObject.status.succeeded ?? 0),
},
{
title: "Failed",
value: String(jobObject.status.failed ?? 0),
},
{
title: "Start Time",
value: jobObject.status.startTime || "N/A",
},
{
title: "Completion Time",
value: jobObject.status.completionTime || "N/A",
},
{
title: "Created",
value: jobObject.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={jobObject?.metadata.labels || {}}
annotations={jobObject?.metadata.annotations || {}}
conditions={jobObject?.status.conditions}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="Job Events"
description="Kubernetes events for this job in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="Job"
resourceName={jobName}
namespace={jobObject?.metadata.namespace}
/>
</Card>
),
},
{
name: "Metrics",
children: (
<Card
title={`Job Metrics: ${jobName}`}
description="CPU and memory usage for pods in this job over the last 6 hours."
>
<KubernetesMetricsTab queryConfigs={[cpuQuery, memoryQuery]} />
</Card>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Job Name" value={jobName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
};
export default KubernetesClusterJobDetail;

View File

@@ -0,0 +1,100 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterJobs: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const jobList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.job.name",
});
setResources(jobList);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<Fragment>
<KubernetesResourceTable
title="Jobs"
description="All jobs in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
);
};
export default KubernetesClusterJobs;

View File

@@ -0,0 +1,242 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesNamespaceObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterNamespaceDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const namespaceName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [namespaceObject, setNamespaceObject] =
useState<KubernetesNamespaceObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
// Fetch the K8s namespace object for overview tab
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchNamespaceObject: () => Promise<void> =
async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesNamespaceObject | null =
await fetchLatestK8sObject<KubernetesNamespaceObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "namespaces",
resourceName: namespaceName,
});
setNamespaceObject(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchNamespaceObject().catch(() => {});
}, [cluster?.clusterIdentifier, namespaceName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const getSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const podName: string =
(attributes["resource.k8s.pod.name"] as string) || "Unknown Pod";
return { title: podName };
};
const cpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "namespace_cpu",
title: "Pod CPU Utilization",
description: `CPU utilization for pods in namespace ${namespaceName}`,
legend: "CPU",
legendUnit: "%",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.cpu.utilization",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.namespace.name": namespaceName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
const memoryQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "namespace_memory",
title: "Pod Memory Usage",
description: `Memory usage for pods in namespace ${namespaceName}`,
legend: "Memory",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.memory.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.namespace.name": namespaceName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
// Build overview summary fields from namespace object
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "Namespace Name", value: namespaceName },
{ title: "Cluster", value: clusterIdentifier },
];
if (namespaceObject) {
summaryFields.push(
{
title: "Status Phase",
value: namespaceObject.status.phase || "N/A",
},
{
title: "Created",
value: namespaceObject.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={namespaceObject?.metadata.labels || {}}
annotations={namespaceObject?.metadata.annotations || {}}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="Namespace Events"
description="Kubernetes events for this namespace in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="Namespace"
resourceName={namespaceName}
/>
</Card>
),
},
{
name: "Metrics",
children: (
<Card
title={`Namespace Metrics: ${namespaceName}`}
description="CPU and memory usage for pods in this namespace over the last 6 hours."
>
<KubernetesMetricsTab queryConfigs={[cpuQuery, memoryQuery]} />
</Card>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Namespace" value={namespaceName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
};
export default KubernetesClusterNamespaceDetail;

View File

@@ -0,0 +1,102 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterNamespaces: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const namespaceList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.namespace.name",
namespaceAttribute: "resource.k8s.namespace.name",
});
setResources(namespaceList);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<Fragment>
<KubernetesResourceTable
title="Namespaces"
description="All namespaces in this cluster."
resources={resources}
showNamespace={false}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
);
};
export default KubernetesClusterNamespaces;

View File

@@ -4,12 +4,8 @@ import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricView from "../../../Components/Metrics/MetricView";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import OneUptimeDate from "Common/Types/Date";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import React, {
Fragment,
FunctionComponent,
@@ -22,16 +18,30 @@ 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 { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import {
KubernetesCondition,
KubernetesNodeObject,
} from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterNodeDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const nodeName: string = Navigation.getLastParam()?.toString() || "";
const nodeName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [nodeObject, setNodeObject] = useState<KubernetesNodeObject | null>(
null,
);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
@@ -56,6 +66,31 @@ const KubernetesClusterNodeDetail: FunctionComponent<
});
}, []);
// Fetch the K8s node object for overview tab
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchNodeObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesNodeObject | null =
await fetchLatestK8sObject<KubernetesNodeObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "nodes",
resourceName: nodeName,
});
setNodeObject(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchNodeObject().catch(() => {});
}, [cluster?.clusterIdentifier, nodeName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
@@ -70,10 +105,6 @@ const KubernetesClusterNodeDetail: FunctionComponent<
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6);
const startAndEndDate: InBetween<Date> = new InBetween(startDate, endDate);
const cpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "node_cpu",
@@ -86,8 +117,8 @@ const KubernetesClusterNodeDetail: FunctionComponent<
filterData: {
metricName: "k8s.node.cpu.utilization",
attributes: {
"k8s.cluster.name": clusterIdentifier,
"k8s.node.name": nodeName,
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.node.name": nodeName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
@@ -110,8 +141,8 @@ const KubernetesClusterNodeDetail: FunctionComponent<
filterData: {
metricName: "k8s.node.memory.usage",
attributes: {
"k8s.cluster.name": clusterIdentifier,
"k8s.node.name": nodeName,
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.node.name": nodeName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
@@ -134,8 +165,8 @@ const KubernetesClusterNodeDetail: FunctionComponent<
filterData: {
metricName: "k8s.node.filesystem.usage",
attributes: {
"k8s.cluster.name": clusterIdentifier,
"k8s.node.name": nodeName,
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.node.name": nodeName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
@@ -158,8 +189,8 @@ const KubernetesClusterNodeDetail: FunctionComponent<
filterData: {
metricName: "k8s.node.network.io.receive",
attributes: {
"k8s.cluster.name": clusterIdentifier,
"k8s.node.name": nodeName,
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.node.name": nodeName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
@@ -182,8 +213,8 @@ const KubernetesClusterNodeDetail: FunctionComponent<
filterData: {
metricName: "k8s.node.network.io.transmit",
attributes: {
"k8s.cluster.name": clusterIdentifier,
"k8s.node.name": nodeName,
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.node.name": nodeName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
@@ -194,47 +225,143 @@ const KubernetesClusterNodeDetail: FunctionComponent<
},
};
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
startAndEndDate: startAndEndDate,
queryConfigs: [
cpuQuery,
memoryQuery,
filesystemQuery,
networkRxQuery,
networkTxQuery,
],
formulaConfigs: [],
});
// Determine node status from conditions
const getNodeStatus: () => { label: string; isReady: boolean } = (): {
label: string;
isReady: boolean;
} => {
if (!nodeObject) {
return { label: "Unknown", isReady: false };
}
const readyCondition: KubernetesCondition | undefined =
nodeObject.status.conditions.find((c: KubernetesCondition) => {
return c.type === "Ready";
});
if (readyCondition && readyCondition.status === "True") {
return { label: "Ready", isReady: true };
}
return { label: "NotReady", isReady: false };
};
// Build overview summary fields from node object
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "Node Name", value: nodeName },
{ title: "Cluster", value: clusterIdentifier },
];
if (nodeObject) {
const nodeStatus: { label: string; isReady: boolean } = getNodeStatus();
summaryFields.push(
{
title: "Status",
value: (
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
nodeStatus.isReady
? "bg-green-50 text-green-700"
: "bg-red-50 text-red-700"
}`}
>
{nodeStatus.label}
</span>
),
},
{
title: "OS Image",
value: nodeObject.status.nodeInfo.osImage || "N/A",
},
{
title: "Kernel",
value: nodeObject.status.nodeInfo.kernelVersion || "N/A",
},
{
title: "Container Runtime",
value: nodeObject.status.nodeInfo.containerRuntimeVersion || "N/A",
},
{
title: "Kubelet Version",
value: nodeObject.status.nodeInfo.kubeletVersion || "N/A",
},
{
title: "Architecture",
value: nodeObject.status.nodeInfo.architecture || "N/A",
},
{
title: "CPU Allocatable",
value: nodeObject.status.allocatable["cpu"] || "N/A",
},
{
title: "Memory Allocatable",
value: nodeObject.status.allocatable["memory"] || "N/A",
},
{
title: "Created",
value: nodeObject.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={nodeObject?.metadata.labels || {}}
annotations={nodeObject?.metadata.annotations || {}}
conditions={nodeObject?.status.conditions}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="Node Events"
description="Kubernetes events for this node in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="Node"
resourceName={nodeName}
/>
</Card>
),
},
{
name: "Metrics",
children: (
<Card
title={`Node Metrics: ${nodeName}`}
description="CPU, memory, filesystem, and network usage for this node over the last 6 hours."
>
<KubernetesMetricsTab
queryConfigs={[
cpuQuery,
memoryQuery,
filesystemQuery,
networkRxQuery,
networkTxQuery,
]}
/>
</Card>
),
},
];
return (
<Fragment>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Node Name" value={nodeName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Node Name" value={nodeName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Card
title={`Node Metrics: ${nodeName}`}
description="CPU, memory, filesystem, and network usage for this node over the last 6 hours."
>
<MetricView
data={metricViewData}
hideQueryElements={true}
onChange={(data: MetricViewData) => {
setMetricViewData({
...data,
queryConfigs: [
cpuQuery,
memoryQuery,
filesystemQuery,
networkRxQuery,
networkTxQuery,
],
formulaConfigs: [],
});
}}
/>
</Card>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
};

View File

@@ -2,14 +2,10 @@ import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import MetricView from "../../../Components/Metrics/MetricView";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import OneUptimeDate from "Common/Types/Date";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
@@ -22,31 +18,46 @@ 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 { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterNodes: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [metricViewData, setMetricViewData] = useState<MetricViewData | null>(
null,
);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const nodeList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.node.cpu.utilization",
memoryMetricName: "k8s.node.memory.usage",
resourceNameAttribute: "resource.k8s.node.name",
namespaceAttribute: "resource.k8s.node.name",
});
setResources(nodeList);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
@@ -54,140 +65,11 @@ const KubernetesClusterNodes: FunctionComponent<
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
useEffect(() => {
if (!cluster) {
return;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6);
const startAndEndDate: InBetween<Date> = new InBetween(startDate, endDate);
const getNodeSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const nodeName: string =
(attributes["resource.k8s.node.name"] as string) || "Unknown Node";
return { title: nodeName };
};
const nodeCpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "node_cpu",
title: "Node CPU Utilization",
description: "CPU utilization by node",
legend: "CPU",
legendUnit: "%",
},
metricQueryData: {
filterData: {
metricName: "k8s.node.cpu.utilization",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getNodeSeries,
};
const nodeMemoryQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "node_memory",
title: "Node Memory Usage",
description: "Memory usage by node",
legend: "Memory",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "k8s.node.memory.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getNodeSeries,
};
const nodeFilesystemQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "node_filesystem",
title: "Node Filesystem Usage",
description: "Filesystem usage by node",
legend: "Filesystem",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "k8s.node.filesystem.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getNodeSeries,
};
const nodeNetworkRxQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "node_network_rx",
title: "Node Network Receive",
description: "Network bytes received by node",
legend: "Network RX",
legendUnit: "bytes/s",
},
metricQueryData: {
filterData: {
metricName: "k8s.node.network.io",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"metricAttributes.direction": "receive",
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getNodeSeries,
};
setMetricViewData({
startAndEndDate: startAndEndDate,
queryConfigs: [
nodeCpuQuery,
nodeMemoryQuery,
nodeFilesystemQuery,
nodeNetworkRxQuery,
],
formulaConfigs: [],
});
}, [cluster]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
@@ -196,21 +78,21 @@ const KubernetesClusterNodes: FunctionComponent<
return <ErrorMessage message={error} />;
}
if (!cluster || !metricViewData) {
return <ErrorMessage message="Cluster not found." />;
}
return (
<Fragment>
<MetricView
data={metricViewData}
hideQueryElements={true}
onChange={(data: MetricViewData) => {
setMetricViewData({
...data,
queryConfigs: metricViewData.queryConfigs,
formulaConfigs: [],
});
<KubernetesResourceTable
title="Nodes"
description="All nodes in this cluster with their current resource usage."
resources={resources}
showNamespace={false}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>

View File

@@ -4,14 +4,10 @@ import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricView from "../../../Components/Metrics/MetricView";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import OneUptimeDate from "Common/Types/Date";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import React, {
Fragment,
FunctionComponent,
@@ -25,16 +21,27 @@ import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesContainersTab from "../../../Components/Kubernetes/KubernetesContainersTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import KubernetesLogsTab from "../../../Components/Kubernetes/KubernetesLogsTab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesPodObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterPodDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const podName: string = Navigation.getLastParam()?.toString() || "";
const podName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [podObject, setPodObject] = useState<KubernetesPodObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
@@ -59,6 +66,31 @@ const KubernetesClusterPodDetail: FunctionComponent<
});
}, []);
// Fetch the K8s pod object for overview/containers tabs
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchPodObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesPodObject | null =
await fetchLatestK8sObject<KubernetesPodObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "pods",
resourceName: podName,
});
setPodObject(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchPodObject().catch(() => {});
}, [cluster?.clusterIdentifier, podName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
@@ -73,17 +105,14 @@ const KubernetesClusterPodDetail: FunctionComponent<
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6);
const startAndEndDate: InBetween<Date> = new InBetween(startDate, endDate);
const getContainerSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const containerName: string =
(attributes["k8s.container.name"] as string) || "Unknown Container";
(attributes["resource.k8s.container.name"] as string) ||
"Unknown Container";
return { title: containerName };
};
@@ -99,8 +128,8 @@ const KubernetesClusterPodDetail: FunctionComponent<
filterData: {
metricName: "container.cpu.utilization",
attributes: {
"k8s.cluster.name": clusterIdentifier,
"k8s.pod.name": podName,
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.pod.name": podName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
@@ -124,8 +153,8 @@ const KubernetesClusterPodDetail: FunctionComponent<
filterData: {
metricName: "container.memory.usage",
attributes: {
"k8s.cluster.name": clusterIdentifier,
"k8s.pod.name": podName,
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.pod.name": podName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
@@ -149,8 +178,8 @@ const KubernetesClusterPodDetail: FunctionComponent<
filterData: {
metricName: "k8s.pod.cpu.utilization",
attributes: {
"k8s.cluster.name": clusterIdentifier,
"k8s.pod.name": podName,
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.pod.name": podName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
@@ -173,8 +202,8 @@ const KubernetesClusterPodDetail: FunctionComponent<
filterData: {
metricName: "k8s.pod.memory.usage",
attributes: {
"k8s.cluster.name": clusterIdentifier,
"k8s.pod.name": podName,
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.pod.name": podName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
@@ -185,40 +214,139 @@ const KubernetesClusterPodDetail: FunctionComponent<
},
};
const [metricViewData, setMetricViewData] = useState<MetricViewData>({
startAndEndDate: startAndEndDate,
queryConfigs: [podCpuQuery, podMemoryQuery, cpuQuery, memoryQuery],
formulaConfigs: [],
});
// Build overview summary fields from pod object
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "Pod Name", value: podName },
{ title: "Cluster", value: clusterIdentifier },
];
if (podObject) {
summaryFields.push(
{
title: "Namespace",
value: podObject.metadata.namespace || "default",
},
{
title: "Status",
value: (
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
podObject.status.phase === "Running"
? "bg-green-50 text-green-700"
: podObject.status.phase === "Succeeded"
? "bg-blue-50 text-blue-700"
: podObject.status.phase === "Failed"
? "bg-red-50 text-red-700"
: "bg-yellow-50 text-yellow-700"
}`}
>
{podObject.status.phase || "Unknown"}
</span>
),
},
{ title: "Node", value: podObject.spec.nodeName || "N/A" },
{ title: "Pod IP", value: podObject.status.podIP || "N/A" },
{ title: "Host IP", value: podObject.status.hostIP || "N/A" },
{
title: "Service Account",
value: podObject.spec.serviceAccountName || "default",
},
{
title: "Created",
value: podObject.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={podObject?.metadata.labels || {}}
annotations={podObject?.metadata.annotations || {}}
conditions={podObject?.status.conditions}
ownerReferences={podObject?.metadata.ownerReferences}
isLoading={isLoadingObject}
/>
),
},
{
name: "Containers",
children: podObject ? (
<KubernetesContainersTab
containers={podObject.spec.containers}
initContainers={podObject.spec.initContainers}
containerStatuses={podObject.status.containerStatuses}
initContainerStatuses={podObject.status.initContainerStatuses}
/>
) : isLoadingObject ? (
<PageLoader isVisible={true} />
) : (
<div className="text-gray-500 text-sm p-4">
Container details not yet available. Ensure the kubernetes-agent Helm
chart has resourceSpecs.enabled set to true.
</div>
),
},
{
name: "Events",
children: (
<Card
title="Pod Events"
description="Kubernetes events for this pod in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="Pod"
resourceName={podName}
namespace={podObject?.metadata.namespace}
/>
</Card>
),
},
{
name: "Logs",
children: (
<Card
title="Application Logs"
description="Container logs for this pod from the last 6 hours."
>
<KubernetesLogsTab
clusterIdentifier={clusterIdentifier}
podName={podName}
namespace={podObject?.metadata.namespace}
/>
</Card>
),
},
{
name: "Metrics",
children: (
<Card
title={`Pod Metrics: ${podName}`}
description="CPU, memory, and container-level resource usage for this pod over the last 6 hours."
>
<KubernetesMetricsTab
queryConfigs={[podCpuQuery, podMemoryQuery, cpuQuery, memoryQuery]}
/>
</Card>
),
},
];
return (
<Fragment>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Pod Name" value={podName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Pod Name" value={podName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Card
title={`Pod Metrics: ${podName}`}
description="CPU, memory, and container-level resource usage for this pod over the last 6 hours."
>
<MetricView
data={metricViewData}
hideQueryElements={true}
onChange={(data: MetricViewData) => {
setMetricViewData({
...data,
queryConfigs: [
podCpuQuery,
podMemoryQuery,
cpuQuery,
memoryQuery,
],
formulaConfigs: [],
});
}}
/>
</Card>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
};

View File

@@ -2,14 +2,10 @@ import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import MetricView from "../../../Components/Metrics/MetricView";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import OneUptimeDate from "Common/Types/Date";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
@@ -22,31 +18,49 @@ 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 { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterPods: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [metricViewData, setMetricViewData] = useState<MetricViewData | null>(
null,
);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const podList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.pod.name",
additionalAttributes: [
"resource.k8s.node.name",
"resource.k8s.deployment.name",
],
});
setResources(podList);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
@@ -54,143 +68,11 @@ const KubernetesClusterPods: FunctionComponent<
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
useEffect(() => {
if (!cluster) {
return;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6);
const startAndEndDate: InBetween<Date> = new InBetween(startDate, endDate);
const getPodSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const podName: string =
(attributes["resource.k8s.pod.name"] as string) || "Unknown Pod";
const namespace: string =
(attributes["resource.k8s.namespace.name"] as string) || "";
return { title: namespace ? `${namespace}/${podName}` : podName };
};
const podCpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "pod_cpu",
title: "Pod CPU Utilization",
description: "CPU utilization by pod",
legend: "CPU",
legendUnit: "%",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.cpu.utilization",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getPodSeries,
};
const podMemoryQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "pod_memory",
title: "Pod Memory Usage",
description: "Memory usage by pod",
legend: "Memory",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.memory.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getPodSeries,
};
const podNetworkRxQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "pod_network_rx",
title: "Pod Network Receive",
description: "Network bytes received by pod",
legend: "Network RX",
legendUnit: "bytes/s",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.network.io",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"metricAttributes.direction": "receive",
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getPodSeries,
};
const podNetworkTxQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "pod_network_tx",
title: "Pod Network Transmit",
description: "Network bytes transmitted by pod",
legend: "Network TX",
legendUnit: "bytes/s",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.network.io",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"metricAttributes.direction": "transmit",
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getPodSeries,
};
setMetricViewData({
startAndEndDate: startAndEndDate,
queryConfigs: [
podCpuQuery,
podMemoryQuery,
podNetworkRxQuery,
podNetworkTxQuery,
],
formulaConfigs: [],
});
}, [cluster]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
@@ -199,21 +81,26 @@ const KubernetesClusterPods: FunctionComponent<
return <ErrorMessage message={error} />;
}
if (!cluster || !metricViewData) {
return <ErrorMessage message="Cluster not found." />;
}
return (
<Fragment>
<MetricView
data={metricViewData}
hideQueryElements={true}
onChange={(data: MetricViewData) => {
setMetricViewData({
...data,
queryConfigs: metricViewData.queryConfigs,
formulaConfigs: [],
});
<KubernetesResourceTable
title="Pods"
description="All pods running in this cluster with their current resource usage."
resources={resources}
columns={[
{
title: "Node",
key: "resource.k8s.node.name",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>

View File

@@ -40,7 +40,17 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
/>
</SideMenuSection>
<SideMenuSection title="Resources">
<SideMenuSection title="Workloads">
<SideMenuItem
link={{
title: "Namespaces",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Folder}
/>
<SideMenuItem
link={{
title: "Pods",
@@ -51,6 +61,59 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
}}
icon={IconProp.Circle}
/>
<SideMenuItem
link={{
title: "Deployments",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Layers}
/>
<SideMenuItem
link={{
title: "StatefulSets",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Database}
/>
<SideMenuItem
link={{
title: "DaemonSets",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Settings}
/>
<SideMenuItem
link={{
title: "Jobs",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_JOBS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Play}
/>
<SideMenuItem
link={{
title: "CronJobs",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Clock}
/>
</SideMenuSection>
<SideMenuSection title="Infrastructure">
<SideMenuItem
link={{
title: "Nodes",
@@ -61,6 +124,16 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
}}
icon={IconProp.Server}
/>
<SideMenuItem
link={{
title: "Containers",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Cube}
/>
</SideMenuSection>
<SideMenuSection title="Observability">
@@ -87,6 +160,16 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
</SideMenuSection>
<SideMenuSection title="Advanced">
<SideMenuItem
link={{
title: "Settings",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Settings}
/>
<SideMenuItem
link={{
title: "Delete Cluster",

View File

@@ -0,0 +1,262 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import AggregateModel from "Common/Types/BaseDatabase/AggregatedModel";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesStatefulSetObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterStatefulSetDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const statefulSetName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [objectData, setObjectData] =
useState<KubernetesStatefulSetObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
// Fetch the K8s statefulset object for overview tab
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesStatefulSetObject | null =
await fetchLatestK8sObject<KubernetesStatefulSetObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "statefulsets",
resourceName: statefulSetName,
});
setObjectData(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchObject().catch(() => {});
}, [cluster?.clusterIdentifier, statefulSetName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const getSeries: (data: AggregateModel) => ChartSeries = (
data: AggregateModel,
): ChartSeries => {
const attributes: Record<string, unknown> =
(data["attributes"] as Record<string, unknown>) || {};
const podName: string =
(attributes["resource.k8s.pod.name"] as string) || "Unknown Pod";
return { title: podName };
};
const cpuQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "statefulset_cpu",
title: "Pod CPU Utilization",
description: `CPU utilization for pods in statefulset ${statefulSetName}`,
legend: "CPU",
legendUnit: "%",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.cpu.utilization",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.statefulset.name": statefulSetName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
const memoryQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "statefulset_memory",
title: "Pod Memory Usage",
description: `Memory usage for pods in statefulset ${statefulSetName}`,
legend: "Memory",
legendUnit: "bytes",
},
metricQueryData: {
filterData: {
metricName: "k8s.pod.memory.usage",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
"resource.k8s.statefulset.name": statefulSetName,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
getSeries: getSeries,
};
// Build overview summary fields from statefulset object
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "Name", value: statefulSetName },
{ title: "Cluster", value: clusterIdentifier },
];
if (objectData) {
summaryFields.push(
{
title: "Namespace",
value: objectData.metadata.namespace || "default",
},
{
title: "Replicas",
value: String(objectData.spec.replicas ?? "N/A"),
},
{
title: "Ready Replicas",
value: String(objectData.status.readyReplicas ?? "N/A"),
},
{
title: "Service Name",
value: objectData.spec.serviceName || "N/A",
},
{
title: "Pod Management Policy",
value: objectData.spec.podManagementPolicy || "N/A",
},
{
title: "Update Strategy",
value: objectData.spec.updateStrategy || "N/A",
},
{
title: "Created",
value: objectData.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={objectData?.metadata.labels || {}}
annotations={objectData?.metadata.annotations || {}}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="StatefulSet Events"
description="Kubernetes events for this statefulset in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="StatefulSet"
resourceName={statefulSetName}
namespace={objectData?.metadata.namespace}
/>
</Card>
),
},
{
name: "Metrics",
children: (
<Card
title={`StatefulSet Metrics: ${statefulSetName}`}
description="CPU and memory usage for pods in this statefulset over the last 6 hours."
>
<KubernetesMetricsTab queryConfigs={[cpuQuery, memoryQuery]} />
</Card>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="StatefulSet" value={statefulSetName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
};
export default KubernetesClusterStatefulSetDetail;

View File

@@ -0,0 +1,102 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterStatefulSets: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const statefulsetList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.statefulset.name",
});
setResources(statefulsetList);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<Fragment>
<KubernetesResourceTable
title="StatefulSets"
description="All statefulsets running in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
);
};
export default KubernetesClusterStatefulSets;

View File

@@ -10,13 +10,28 @@ import { Route as PageRoute, Routes } from "react-router-dom";
// Pages
import KubernetesClusters from "../Pages/Kubernetes/Clusters";
import KubernetesClusterView from "../Pages/Kubernetes/View/Index";
import KubernetesClusterViewNamespaces from "../Pages/Kubernetes/View/Namespaces";
import KubernetesClusterViewNamespaceDetail from "../Pages/Kubernetes/View/NamespaceDetail";
import KubernetesClusterViewPods from "../Pages/Kubernetes/View/Pods";
import KubernetesClusterViewPodDetail from "../Pages/Kubernetes/View/PodDetail";
import KubernetesClusterViewDeployments from "../Pages/Kubernetes/View/Deployments";
import KubernetesClusterViewDeploymentDetail from "../Pages/Kubernetes/View/DeploymentDetail";
import KubernetesClusterViewStatefulSets from "../Pages/Kubernetes/View/StatefulSets";
import KubernetesClusterViewStatefulSetDetail from "../Pages/Kubernetes/View/StatefulSetDetail";
import KubernetesClusterViewDaemonSets from "../Pages/Kubernetes/View/DaemonSets";
import KubernetesClusterViewDaemonSetDetail from "../Pages/Kubernetes/View/DaemonSetDetail";
import KubernetesClusterViewJobs from "../Pages/Kubernetes/View/Jobs";
import KubernetesClusterViewJobDetail from "../Pages/Kubernetes/View/JobDetail";
import KubernetesClusterViewCronJobs from "../Pages/Kubernetes/View/CronJobs";
import KubernetesClusterViewCronJobDetail from "../Pages/Kubernetes/View/CronJobDetail";
import KubernetesClusterViewNodes from "../Pages/Kubernetes/View/Nodes";
import KubernetesClusterViewNodeDetail from "../Pages/Kubernetes/View/NodeDetail";
import KubernetesClusterViewContainers from "../Pages/Kubernetes/View/Containers";
import KubernetesClusterViewContainerDetail from "../Pages/Kubernetes/View/ContainerDetail";
import KubernetesClusterViewEvents from "../Pages/Kubernetes/View/Events";
import KubernetesClusterViewControlPlane from "../Pages/Kubernetes/View/ControlPlane";
import KubernetesClusterViewDelete from "../Pages/Kubernetes/View/Delete";
import KubernetesClusterViewSettings from "../Pages/Kubernetes/View/Settings";
import KubernetesClusterViewDocumentation from "../Pages/Kubernetes/View/Documentation";
import KubernetesDocumentation from "../Pages/Kubernetes/Documentation";
@@ -60,6 +75,39 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
}
/>
{/* Namespaces */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES,
)}
element={
<KubernetesClusterViewNamespaces
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL,
2,
)}
element={
<KubernetesClusterViewNamespaceDetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL
] as Route
}
/>
}
/>
{/* Pods */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_PODS,
@@ -77,6 +125,7 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL,
2,
)}
element={
<KubernetesClusterViewPodDetail
@@ -88,6 +137,165 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
}
/>
{/* Deployments */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS,
)}
element={
<KubernetesClusterViewDeployments
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL,
2,
)}
element={
<KubernetesClusterViewDeploymentDetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL
] as Route
}
/>
}
/>
{/* StatefulSets */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS,
)}
element={
<KubernetesClusterViewStatefulSets
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL,
2,
)}
element={
<KubernetesClusterViewStatefulSetDetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL
] as Route
}
/>
}
/>
{/* DaemonSets */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS,
)}
element={
<KubernetesClusterViewDaemonSets
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL,
2,
)}
element={
<KubernetesClusterViewDaemonSetDetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL
] as Route
}
/>
}
/>
{/* Jobs */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_JOBS,
)}
element={
<KubernetesClusterViewJobs
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_JOBS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL,
2,
)}
element={
<KubernetesClusterViewJobDetail
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL] as Route
}
/>
}
/>
{/* CronJobs */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS,
)}
element={
<KubernetesClusterViewCronJobs
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL,
2,
)}
element={
<KubernetesClusterViewCronJobDetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL
] as Route
}
/>
}
/>
{/* Nodes */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_NODES,
@@ -105,6 +313,7 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL,
2,
)}
element={
<KubernetesClusterViewNodeDetail
@@ -116,6 +325,39 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
}
/>
{/* Containers */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS,
)}
element={
<KubernetesClusterViewContainers
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL,
2,
)}
element={
<KubernetesClusterViewContainerDetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL
] as Route
}
/>
}
/>
{/* Events */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS,
@@ -130,6 +372,7 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
}
/>
{/* Control Plane */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE,
@@ -144,6 +387,22 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
}
/>
{/* Settings */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS,
)}
element={
<KubernetesClusterViewSettings
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS] as Route
}
/>
}
/>
{/* Delete */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_DELETE,
@@ -158,6 +417,7 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
}
/>
{/* Documentation */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_DOCUMENTATION,

View File

@@ -17,6 +17,24 @@ export function getKubernetesBreadcrumbs(
"Kubernetes",
"View Cluster",
]),
// Namespaces
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES,
["Project", "Kubernetes", "View Cluster", "Namespaces"],
),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL,
[
"Project",
"Kubernetes",
"View Cluster",
"Namespaces",
"Namespace Detail",
],
),
// Pods
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_PODS, [
"Project",
"Kubernetes",
@@ -27,6 +45,80 @@ export function getKubernetesBreadcrumbs(
PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL,
["Project", "Kubernetes", "View Cluster", "Pods", "Pod Detail"],
),
// Deployments
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS,
["Project", "Kubernetes", "View Cluster", "Deployments"],
),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL,
[
"Project",
"Kubernetes",
"View Cluster",
"Deployments",
"Deployment Detail",
],
),
// StatefulSets
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS,
["Project", "Kubernetes", "View Cluster", "StatefulSets"],
),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL,
[
"Project",
"Kubernetes",
"View Cluster",
"StatefulSets",
"StatefulSet Detail",
],
),
// DaemonSets
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS,
["Project", "Kubernetes", "View Cluster", "DaemonSets"],
),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL,
[
"Project",
"Kubernetes",
"View Cluster",
"DaemonSets",
"DaemonSet Detail",
],
),
// Jobs
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_JOBS, [
"Project",
"Kubernetes",
"View Cluster",
"Jobs",
]),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL,
["Project", "Kubernetes", "View Cluster", "Jobs", "Job Detail"],
),
// CronJobs
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS, [
"Project",
"Kubernetes",
"View Cluster",
"CronJobs",
]),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL,
["Project", "Kubernetes", "View Cluster", "CronJobs", "CronJob Detail"],
),
// Nodes
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_NODES, [
"Project",
"Kubernetes",
@@ -37,6 +129,24 @@ export function getKubernetesBreadcrumbs(
PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL,
["Project", "Kubernetes", "View Cluster", "Nodes", "Node Detail"],
),
// Containers
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS,
["Project", "Kubernetes", "View Cluster", "Containers"],
),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL,
[
"Project",
"Kubernetes",
"View Cluster",
"Containers",
"Container Detail",
],
),
// Observability
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS, [
"Project",
"Kubernetes",
@@ -47,12 +157,20 @@ export function getKubernetesBreadcrumbs(
PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE,
["Project", "Kubernetes", "View Cluster", "Control Plane"],
),
// Advanced
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_DELETE, [
"Project",
"Kubernetes",
"View Cluster",
"Delete Cluster",
]),
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS, [
"Project",
"Kubernetes",
"View Cluster",
"Settings",
]),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_DOCUMENTATION,
["Project", "Kubernetes", "View Cluster", "Documentation"],

View File

@@ -220,10 +220,24 @@ enum PageMap {
KUBERNETES_ROOT = "KUBERNETES_ROOT",
KUBERNETES_CLUSTERS = "KUBERNETES_CLUSTERS",
KUBERNETES_CLUSTER_VIEW = "KUBERNETES_CLUSTER_VIEW",
KUBERNETES_CLUSTER_VIEW_NAMESPACES = "KUBERNETES_CLUSTER_VIEW_NAMESPACES",
KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL = "KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL",
KUBERNETES_CLUSTER_VIEW_PODS = "KUBERNETES_CLUSTER_VIEW_PODS",
KUBERNETES_CLUSTER_VIEW_POD_DETAIL = "KUBERNETES_CLUSTER_VIEW_POD_DETAIL",
KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS = "KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS",
KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL = "KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL",
KUBERNETES_CLUSTER_VIEW_STATEFULSETS = "KUBERNETES_CLUSTER_VIEW_STATEFULSETS",
KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL = "KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL",
KUBERNETES_CLUSTER_VIEW_DAEMONSETS = "KUBERNETES_CLUSTER_VIEW_DAEMONSETS",
KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL = "KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL",
KUBERNETES_CLUSTER_VIEW_JOBS = "KUBERNETES_CLUSTER_VIEW_JOBS",
KUBERNETES_CLUSTER_VIEW_JOB_DETAIL = "KUBERNETES_CLUSTER_VIEW_JOB_DETAIL",
KUBERNETES_CLUSTER_VIEW_CRONJOBS = "KUBERNETES_CLUSTER_VIEW_CRONJOBS",
KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL = "KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL",
KUBERNETES_CLUSTER_VIEW_NODES = "KUBERNETES_CLUSTER_VIEW_NODES",
KUBERNETES_CLUSTER_VIEW_NODE_DETAIL = "KUBERNETES_CLUSTER_VIEW_NODE_DETAIL",
KUBERNETES_CLUSTER_VIEW_CONTAINERS = "KUBERNETES_CLUSTER_VIEW_CONTAINERS",
KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL = "KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL",
KUBERNETES_CLUSTER_VIEW_EVENTS = "KUBERNETES_CLUSTER_VIEW_EVENTS",
KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE = "KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE",
KUBERNETES_CLUSTER_VIEW_DELETE = "KUBERNETES_CLUSTER_VIEW_DELETE",

View File

@@ -61,10 +61,24 @@ export const CodeRepositoryRoutePath: Dictionary<string> = {
export const KubernetesRoutePath: Dictionary<string> = {
[PageMap.KUBERNETES_CLUSTER_VIEW]: `${RouteParams.ModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES]: `${RouteParams.ModelID}/namespaces`,
[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL]: `${RouteParams.ModelID}/namespaces/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_PODS]: `${RouteParams.ModelID}/pods`,
[PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL]: `${RouteParams.ModelID}/pods/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS]: `${RouteParams.ModelID}/deployments`,
[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL]: `${RouteParams.ModelID}/deployments/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS]: `${RouteParams.ModelID}/statefulsets`,
[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL]: `${RouteParams.ModelID}/statefulsets/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS]: `${RouteParams.ModelID}/daemonsets`,
[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL]: `${RouteParams.ModelID}/daemonsets/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_JOBS]: `${RouteParams.ModelID}/jobs`,
[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL]: `${RouteParams.ModelID}/jobs/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS]: `${RouteParams.ModelID}/cronjobs`,
[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL]: `${RouteParams.ModelID}/cronjobs/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_NODES]: `${RouteParams.ModelID}/nodes`,
[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL]: `${RouteParams.ModelID}/nodes/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS]: `${RouteParams.ModelID}/containers`,
[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL]: `${RouteParams.ModelID}/containers/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: `${RouteParams.ModelID}/events`,
[PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE]: `${RouteParams.ModelID}/control-plane`,
[PageMap.KUBERNETES_CLUSTER_VIEW_DELETE]: `${RouteParams.ModelID}/delete`,
@@ -1499,6 +1513,18 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_PODS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PODS]
@@ -1511,6 +1537,66 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_JOBS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_JOBS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_NODES]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NODES]
@@ -1523,6 +1609,18 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]

View File

@@ -4,7 +4,24 @@ If you are using OneUptime.com and want to whitelist our IP's for security reaso
Please whitelist the following IP's in your firewall to allow oneuptime.com to reach your resources.
- 172.174.206.132
- 57.151.99.117
{{IP_WHITELIST}}
These IP's can change, we will let you know in advance if this happens.
These IP's can change, we will let you know in advance if this happens.
## Fetch IP Addresses Programmatically
You can also fetch the list of probe egress IP addresses programmatically via the following API endpoint:
```
GET https://oneuptime.com/ip-whitelist
```
This returns a JSON response:
```json
{
"ipWhitelist": ["<list of IPs>"]
}
```
You can use this endpoint to keep your firewall whitelist updated automatically.

View File

@@ -13,7 +13,7 @@ import Response from "Common/Server/Utils/Response";
import LocalFile from "Common/Server/Utils/LocalFile";
import logger from "Common/Server/Utils/Logger";
import "ejs";
import { IsBillingEnabled } from "Common/Server/EnvironmentConfig";
import { IsBillingEnabled, IpWhitelist } from "Common/Server/EnvironmentConfig";
const DocsFeatureSet: FeatureSet = {
init: async (): Promise<void> => {
@@ -78,6 +78,24 @@ const DocsFeatureSet: FeatureSet = {
// Remove first line (title) from content as it is already present in the navigation
contentInMarkdown = contentInMarkdown.split("\n").slice(1).join("\n");
// Replace dynamic placeholders in markdown content
if (contentInMarkdown.includes("{{IP_WHITELIST}}")) {
const ipList: string = IpWhitelist
? IpWhitelist.split(",")
.map((ip: string) => {
return `- ${ip.trim()}`;
})
.filter((line: string) => {
return line.length > 2;
})
.join("\n")
: "- No IP addresses configured.";
contentInMarkdown = contentInMarkdown.replace(
"{{IP_WHITELIST}}",
ipList,
);
}
// Render Markdown content to HTML
const renderedContent: string =
await DocsRender.render(contentInMarkdown);

View File

@@ -575,6 +575,11 @@ router.post(
},
});
// Revoke all active sessions for this user on password reset
await UserSessionService.revokeAllSessionsByUserId(alreadySavedUser.id!, {
reason: "Password reset",
});
const host: Hostname = await DatabaseConfig.getHost();
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();

View File

@@ -0,0 +1,31 @@
import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
} from "../Utils/Express";
import Response from "../Utils/Response";
import { IpWhitelist } from "../EnvironmentConfig";
export default class IPWhitelistAPI {
public static init(): ExpressRouter {
const router: ExpressRouter = Express.getRouter();
router.get("/ip-whitelist", (req: ExpressRequest, res: ExpressResponse) => {
const ipList: Array<string> = IpWhitelist
? IpWhitelist.split(",")
.map((ip: string) => {
return ip.trim();
})
.filter((ip: string) => {
return ip.length > 0;
})
: [];
Response.sendJsonObjectResponse(req, res, {
ipWhitelist: ipList,
});
});
return router;
}
}

View File

@@ -1,5 +1,6 @@
import Express, { ExpressApplication } from "../Utils/Express";
import StatusAPI, { StatusAPIOptions } from "./StatusAPI";
import IPWhitelistAPI from "./IPWhitelistAPI";
import version from "./VersionAPI";
const app: ExpressApplication = Express.getExpressApp();
@@ -14,6 +15,7 @@ type InitFunction = (data: InitOptions) => void;
const init: InitFunction = (data: InitOptions): void => {
app.use([`/${data.appName}`, "/"], version);
app.use([`/${data.appName}`, "/"], StatusAPI.init(data.statusOptions));
app.use([`/${data.appName}`, "/"], IPWhitelistAPI.init());
};
export default init;

View File

@@ -397,6 +397,8 @@ export const DocsClientUrl: URL = new URL(
new Route(DocsRoute.toString()),
);
export const IpWhitelist: string = process.env["IP_WHITELIST"] || "";
export const DisableTelemetry: boolean =
process.env["DISABLE_TELEMETRY"] === "true";

View File

@@ -13,6 +13,7 @@ import MailService from "./MailService";
import TeamMemberService from "./TeamMemberService";
import UserNotificationRuleService from "./UserNotificationRuleService";
import UserNotificationSettingService from "./UserNotificationSettingService";
import UserSessionService from "./UserSessionService";
import { AccountsRoute } from "../../ServiceRoute";
import Hostname from "../../Types/API/Hostname";
import Protocol from "../../Types/API/Protocol";
@@ -252,6 +253,11 @@ export class Service extends DatabaseService<Model> {
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
for (const user of onUpdate.carryForward) {
// Revoke all active sessions for this user on password change
await UserSessionService.revokeAllSessionsByUserId(user.id!, {
reason: "Password changed",
});
// password changed, send password changed mail
MailService.sendMail({
toEmail: user.email!,

View File

@@ -1,6 +1,7 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/UserSession";
import ObjectID from "../../Types/ObjectID";
import LIMIT_MAX from "../../Types/Database/LimitMax";
import { JSONObject } from "../../Types/JSON";
import HashedString from "../../Types/HashedString";
import { EncryptionSecret } from "../EnvironmentConfig";
@@ -275,6 +276,28 @@ export class Service extends DatabaseService<Model> {
await this.revokeSessionById(session.id, options);
}
public async revokeAllSessionsByUserId(
userId: ObjectID,
options?: RevokeSessionOptions,
): Promise<void> {
await this.updateBy({
query: {
userId: userId,
isRevoked: false,
},
data: {
isRevoked: true,
revokedAt: OneUptimeDate.getCurrentDate(),
revokedReason: options?.reason ?? null,
},
limit: LIMIT_MAX,
skip: 0,
props: {
isRoot: true,
},
});
}
private buildSessionModel(
options: CreateSessionOptions,
tokenMeta: { refreshToken: string; refreshTokenExpiresAt: Date },

View File

@@ -13,6 +13,21 @@ data:
endpoint: "0.0.0.0:13133"
receivers:
# Collect node, pod, and container resource metrics from kubelet
kubeletstats:
collection_interval: {{ .Values.collectionInterval }}
auth_type: serviceAccount
endpoint: "https://${env:NODE_NAME}:10250"
insecure_skip_verify: true
metric_groups:
- node
- pod
- container
extra_metadata_labels:
- container.id
k8s_api_config:
auth_type: serviceAccount
# Collect pod logs from /var/log/pods
filelog:
include:
@@ -95,11 +110,25 @@ data:
- k8s.replicaset.name
- k8s.statefulset.name
- k8s.daemonset.name
- k8s.job.name
- k8s.cronjob.name
- k8s.container.name
labels:
- tag_name: k8s.pod.label.app
key: app
from: pod
- tag_name: k8s.pod.label.app.kubernetes.io/name
key: app.kubernetes.io/name
from: pod
pod_association:
- sources:
- from: resource_attribute
name: k8s.pod.ip
- sources:
- from: resource_attribute
name: k8s.pod.uid
- sources:
- from: connection
# Stamp with cluster name
resource:
@@ -114,8 +143,8 @@ data:
memory_limiter:
check_interval: 5s
limit_mib: 200
spike_limit_mib: 50
limit_mib: 400
spike_limit_mib: 100
exporters:
otlphttp:
@@ -127,6 +156,16 @@ data:
extensions:
- health_check
pipelines:
metrics:
receivers:
- kubeletstats
processors:
- memory_limiter
- k8sattributes
- resource
- batch
exporters:
- otlphttp
logs:
receivers:
- filelog

View File

@@ -12,21 +12,6 @@ data:
endpoint: "0.0.0.0:13133"
receivers:
# Collect node, pod, and container resource metrics from kubelet
kubeletstats:
collection_interval: {{ .Values.collectionInterval }}
auth_type: serviceAccount
endpoint: "https://${env:NODE_NAME}:10250"
insecure_skip_verify: true
metric_groups:
- node
- pod
- container
extra_metadata_labels:
- container.id
k8s_api_config:
auth_type: serviceAccount
# Collect cluster-level metrics from the Kubernetes API
k8s_cluster:
collection_interval: {{ .Values.collectionInterval }}
@@ -47,6 +32,38 @@ data:
- name: events
mode: watch
group: events.k8s.io
{{- if .Values.resourceSpecs.enabled }}
# Pull full resource specs for dashboard detail views
- name: pods
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
- name: nodes
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
- name: namespaces
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
- name: deployments
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: apps
- name: statefulsets
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: apps
- name: daemonsets
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: apps
- name: jobs
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: batch
- name: cronjobs
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: batch
{{- end }}
{{- if .Values.controlPlane.enabled }}
# Scrape control plane metrics via Prometheus endpoints
@@ -137,21 +154,31 @@ data:
# Batch telemetry for efficient export
batch:
send_batch_size: 200
send_batch_max_size: 500
send_batch_size: 100
send_batch_max_size: 200
timeout: 10s
# Limit memory usage
memory_limiter:
check_interval: 5s
limit_mib: 1500
spike_limit_mib: 300
limit_mib: 3000
spike_limit_mib: 600
exporters:
otlphttp:
endpoint: "{{ .Values.oneuptime.url }}/otlp"
encoding: json
headers:
x-oneuptime-token: "${env:ONEUPTIME_API_KEY}"
sending_queue:
enabled: true
num_consumers: 10
queue_size: 5000
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 60s
max_elapsed_time: 300s
service:
extensions:
@@ -159,7 +186,6 @@ data:
pipelines:
metrics:
receivers:
- kubeletstats
- k8s_cluster
{{- if .Values.controlPlane.enabled }}
- prometheus

View File

@@ -21,6 +21,8 @@ spec:
checksum/config: {{ include (print $.Template.BasePath "/configmap-daemonset.yaml") . | sha256sum }}
spec:
serviceAccountName: {{ include "kubernetes-agent.serviceAccountName" . }}
tolerations:
- operator: Exists
containers:
- name: otel-collector
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"

View File

@@ -184,6 +184,21 @@
},
"additionalProperties": false
},
"resourceSpecs": {
"type": "object",
"description": "Pull full K8s resource specs for dashboard detail views (labels, annotations, env vars, status, etc.)",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable pulling full resource specs via k8sobjects receiver"
},
"interval": {
"type": "string",
"description": "How often to pull resource specs (e.g., 300s, 5m)"
}
},
"additionalProperties": false
},
"collectionInterval": {
"type": "string",
"description": "Collection interval for metrics (e.g., 30s, 1m)"

View File

@@ -30,10 +30,10 @@ deployment:
resources:
requests:
cpu: 200m
memory: 512Mi
memory: 1Gi
limits:
cpu: 1000m
memory: 2Gi
memory: 4Gi
# Control plane monitoring (etcd, API server, scheduler, controller manager)
# Disabled by default — enable for self-managed clusters.
@@ -70,6 +70,13 @@ logs:
cpu: 200m
memory: 256Mi
# Resource spec collection — pulls full K8s resource objects (pods, nodes,
# deployments, etc.) for displaying labels, annotations, env vars, status,
# and other details in the dashboard.
resourceSpecs:
enabled: true
interval: 300s # How often to pull full resource specs (default: 5 minutes)
# Collection intervals
collectionInterval: 30s

View File

@@ -128,6 +128,8 @@ Usage:
value: {{ $.Values.home.ports.http | squote }}
- name: WORKER_PORT
value: {{ $.Values.worker.ports.http | squote }}
- name: IP_WHITELIST
value: {{ default "" $.Values.ipWhitelist | quote }}
{{- end }}

View File

@@ -41,6 +41,10 @@
"encryptionSecret": {
"type": ["string", "null"]
},
"ipWhitelist": {
"type": ["string", "null"],
"description": "Comma-separated list of probe egress IP addresses for firewall whitelisting. Returned via the /ip-whitelist API endpoint."
},
"externalSecrets": {
"type": "object",
"properties": {

View File

@@ -35,6 +35,12 @@ oneuptimeSecret:
registerProbeKey:
encryptionSecret:
# Comma-separated list of egress IP addresses that probes use for monitoring checks.
# Customers can use this to whitelist probe traffic in their firewalls.
# This is returned as a JSON array via the /ip-whitelist API endpoint.
# Example: "203.0.113.1,203.0.113.2,198.51.100.10"
ipWhitelist:
# External Secrets
# You need to leave blank oneuptimeSecret and encryptionSecret to use this section
externalSecrets:
@@ -128,6 +134,8 @@ postgresql:
# pg_hba.conf rules. These enable password auth (md5) from any host/IP.
# Tighten these for production to your pod/service/network CIDRs.
hbaConfiguration: |-
# Local connections (needed for initdb/entrypoint to create databases)
local all all trust
# Allow all IPv4 and IPv6 clients with md5 password auth
host all all 0.0.0.0/0 md5
host all all ::/0 md5

View File

@@ -1208,6 +1208,41 @@ const HomeFeatureSet: FeatureSet = {
},
);
app.get(
"/product/kubernetes",
(_req: ExpressRequest, res: ExpressResponse) => {
const seo: PageSEOData & { fullCanonicalUrl: string } = getSEOForPath(
"/product/kubernetes",
res.locals["homeUrl"] as string,
);
res.render(`${ViewsPath}/kubernetes`, {
enableGoogleTagManager: IsBillingEnabled,
seo,
});
},
);
app.get(
"/product/scheduled-maintenance",
(_req: ExpressRequest, res: ExpressResponse) => {
const seo: PageSEOData & { fullCanonicalUrl: string } = getSEOForPath(
"/product/scheduled-maintenance",
res.locals["homeUrl"] as string,
);
res.render(`${ViewsPath}/scheduled-maintenance`, {
enableGoogleTagManager: IsBillingEnabled,
seo,
});
},
);
app.get(
"/scheduled-maintenance",
(_req: ExpressRequest, res: ExpressResponse) => {
res.redirect("/product/scheduled-maintenance");
},
);
app.get("/product/traces", (_req: ExpressRequest, res: ExpressResponse) => {
const seo: PageSEOData & { fullCanonicalUrl: string } = getSEOForPath(
"/product/traces",

View File

@@ -403,6 +403,77 @@ export const PageSEOConfig: Record<string, PageSEOData> = {
},
},
"/product/kubernetes": {
title:
"Kubernetes Observability | Monitor Clusters, Pods & Nodes | OneUptime",
description:
"Complete Kubernetes observability with real-time cluster monitoring, pod health tracking, node metrics, and automated alerting. OpenTelemetry native. Open source.",
canonicalPath: "/product/kubernetes",
twitterCard: "summary_large_image",
pageType: "product",
breadcrumbs: [
{ name: "Home", url: "/" },
{ name: "Products", url: "/#products" },
{ name: "Kubernetes", url: "/product/kubernetes" },
],
softwareApplication: {
name: "OneUptime Kubernetes Observability",
applicationCategory: "DeveloperApplication",
operatingSystem: "Web, Cloud",
description:
"Monitor Kubernetes clusters, nodes, pods, and containers with real-time metrics, intelligent alerting, and pre-built dashboards.",
features: [
"Multi-cluster monitoring",
"Node health and metrics",
"Pod and container monitoring",
"CrashLoopBackOff detection",
"OOMKill alerting",
"Resource utilization tracking",
"Namespace-level breakdowns",
"OpenTelemetry native",
"DaemonSet deployment",
"Kubelet stats receiver",
"Logs and traces correlation",
],
},
},
"/product/scheduled-maintenance": {
title: "Scheduled Maintenance | Plan & Communicate Downtime | OneUptime",
description:
"Plan, schedule, and communicate maintenance windows to your users. Notify subscribers automatically, update status pages in real-time. Open source maintenance management.",
canonicalPath: "/product/scheduled-maintenance",
twitterCard: "summary_large_image",
pageType: "product",
breadcrumbs: [
{ name: "Home", url: "/" },
{ name: "Products", url: "/#products" },
{
name: "Scheduled Maintenance",
url: "/product/scheduled-maintenance",
},
],
softwareApplication: {
name: "OneUptime Scheduled Maintenance",
applicationCategory: "DeveloperApplication",
operatingSystem: "Web, Cloud",
description:
"Schedule maintenance windows, notify subscribers via email, SMS, and webhooks, and keep your status page updated in real-time.",
features: [
"Maintenance scheduling",
"Subscriber notifications",
"Status page integration",
"Custom maintenance states",
"Email and SMS alerts",
"Webhook integrations",
"Slack and Teams notifications",
"Maintenance timeline",
"Affected monitors tracking",
"Automatic state transitions",
],
},
},
"/product/traces": {
title: "Distributed Tracing | End-to-End Request Tracing | OneUptime",
description:

View File

@@ -37,6 +37,7 @@ const PAGE_CONFIG: Record<string, SitemapPageConfig> = {
"/product/workflows": { priority: 0.9, changefreq: "weekly" },
"/product/dashboards": { priority: 0.9, changefreq: "weekly" },
"/product/ai-agent": { priority: 0.9, changefreq: "weekly" },
"/product/scheduled-maintenance": { priority: 0.9, changefreq: "weekly" },
// Teams (Solutions) pages
"/solutions/devops": { priority: 0.8, changefreq: "weekly" },

View File

@@ -0,0 +1,10 @@
<!-- Scheduled Maintenance - Teal -->
<div class="hero-card-wrapper hero-glow-teal-wrapper h-full">
<a href="/product/scheduled-maintenance" class="hero-card hero-glow-teal group flex flex-col items-center justify-center text-center rounded-2xl bg-white px-4 py-5 ring-1 ring-inset ring-gray-200 transition-all hover:ring-teal-300 h-full">
<div class="flex h-11 w-11 items-center justify-center rounded-xl bg-teal-50 ring-1 ring-teal-200">
<%- include('../icons/scheduled-maintenance') %>
</div>
<div class="mt-3 text-sm font-medium text-gray-900">Maintenance</div>
<div class="mt-1 text-xs text-gray-500">Plan &amp; communicate downtime</div>
</a>
</div>

View File

@@ -0,0 +1,12 @@
<!-- Kubernetes Icon - Helm wheel -->
<svg class="<%= typeof iconClass !== 'undefined' ? iconClass : 'h-5 w-5' %> text-cyan-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<polygon stroke-linecap="round" stroke-linejoin="round" points="12,2.5 19.43,6.08 21.26,14.11 15.95,20.64 8.05,20.56 2.74,14.27 4.57,6.22"></polygon>
<circle cx="12" cy="12" r="2.2" stroke-linecap="round" stroke-linejoin="round"></circle>
<line x1="12" y1="9.8" x2="12" y2="4" stroke-linecap="round"></line>
<line x1="14.12" y1="10.24" x2="17.54" y2="6.64" stroke-linecap="round"></line>
<line x1="14.14" y1="12.49" x2="19.31" y2="13.67" stroke-linecap="round"></line>
<line x1="13.12" y1="14.0" x2="15.12" y2="18.82" stroke-linecap="round"></line>
<line x1="10.88" y1="14.0" x2="8.88" y2="18.76" stroke-linecap="round"></line>
<line x1="9.86" y1="12.49" x2="4.69" y2="13.79" stroke-linecap="round"></line>
<line x1="9.88" y1="10.24" x2="6.46" y2="6.53" stroke-linecap="round"></line>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

View File

@@ -0,0 +1,4 @@
<!-- Scheduled Maintenance Icon - Calendar with wrench -->
<svg class="<%= typeof iconClass !== 'undefined' ? iconClass : 'h-5 w-5' %> text-teal-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z"></path>
</svg>

After

Width:  |  Height:  |  Size: 842 B

1297
Home/Views/kubernetes.ejs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -109,6 +109,17 @@
<p class="text-xs text-gray-500">Smart routing & escalations</p>
</div>
</a>
<!-- Scheduled Maintenance - Teal -->
<a href="/product/scheduled-maintenance" class="group flex items-center gap-3 rounded-lg p-2.5 transition-colors hover:bg-teal-50">
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-teal-50 ring-1 ring-teal-200 group-hover:bg-teal-100 group-hover:ring-teal-300">
<%- include('./Partials/icons/scheduled-maintenance', {iconClass: 'h-4 w-4'}) %>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Scheduled Maintenance</p>
<p class="text-xs text-gray-500">Plan & communicate downtime</p>
</div>
</a>
</div>
</div>
@@ -161,6 +172,17 @@
<p class="text-xs text-gray-500">Error tracking & debugging</p>
</div>
</a>
<!-- Kubernetes - Cyan -->
<a href="/product/kubernetes" class="group flex items-center gap-3 rounded-lg p-2.5 transition-colors hover:bg-cyan-50">
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-cyan-50 ring-1 ring-cyan-200 group-hover:bg-cyan-100 group-hover:ring-cyan-300">
<%- include('./Partials/icons/kubernetes', {iconClass: 'h-4 w-4'}) %>
</div>
<div>
<p class="text-sm font-medium text-gray-900">Kubernetes</p>
<p class="text-xs text-gray-500">Cluster & pod observability</p>
</div>
</a>
</div>
</div>
@@ -764,6 +786,14 @@
<span class="text-sm font-medium text-gray-900">On-Call</span>
</a>
<!-- Scheduled Maintenance - Teal -->
<a href="/product/scheduled-maintenance" class="flex items-center gap-3 rounded-xl p-3 hover:bg-teal-50 ring-1 ring-transparent hover:ring-teal-200">
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-teal-50 ring-1 ring-teal-200">
<%- include('./Partials/icons/scheduled-maintenance', {iconClass: 'h-4 w-4'}) %>
</div>
<span class="text-sm font-medium text-gray-900">Maintenance</span>
</a>
<!-- Logs - Amber -->
<a href="/product/logs-management" class="flex items-center gap-3 rounded-xl p-3 hover:bg-amber-50 ring-1 ring-transparent hover:ring-amber-200">
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-amber-50 ring-1 ring-amber-200">
@@ -796,6 +826,14 @@
<span class="text-sm font-medium text-gray-900">Exceptions</span>
</a>
<!-- Kubernetes - Cyan -->
<a href="/product/kubernetes" class="flex items-center gap-3 rounded-xl p-3 hover:bg-cyan-50 ring-1 ring-transparent hover:ring-cyan-200">
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-cyan-50 ring-1 ring-cyan-200">
<%- include('./Partials/icons/kubernetes', {iconClass: 'h-4 w-4'}) %>
</div>
<span class="text-sm font-medium text-gray-900">Kubernetes</span>
</a>
<!-- Workflows - Sky -->
<a href="/product/workflows" class="flex items-center gap-3 rounded-xl p-3 hover:bg-sky-50 ring-1 ring-transparent hover:ring-sky-200">
<div class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-sky-50 ring-1 ring-sky-200">

View File

@@ -0,0 +1,708 @@
<!DOCTYPE html>
<html lang="en" id="home">
<meta http-equiv="content-type" content="text/html;charset=utf-8" />
<head>
<title>OneUptime | Scheduled Maintenance - Plan & Communicate Downtime</title>
<meta name="description"
content="Plan, schedule, and communicate maintenance windows to your users. Notify subscribers automatically, update status pages in real-time, and keep stakeholders informed. Open source.">
<%- include('head', {
enableGoogleTagManager: typeof enableGoogleTagManager !== 'undefined' ? enableGoogleTagManager : false
}) -%>
</head>
<body>
<%- include('nav') -%>
<main id="main-content">
<!-- Hero Section -->
<div class="relative isolate overflow-hidden bg-white" id="maintenance-hero-section">
<!-- Subtle grid pattern background -->
<div class="absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#f0f0f0_1px,transparent_1px),linear-gradient(to_bottom,#f0f0f0_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_110%)]"></div>
<!-- Grid glow effect that follows cursor - teal color -->
<div id="maintenance-grid-glow" class="absolute inset-0 -z-9 pointer-events-none" style="opacity: 0; transition: opacity 0.3s ease-out; background: linear-gradient(to right, rgba(20, 184, 166, 0.3) 1px, transparent 1px), linear-gradient(to bottom, rgba(20, 184, 166, 0.3) 1px, transparent 1px); background-size: 4rem 4rem; -webkit-mask-image: radial-gradient(circle 250px at var(--mouse-x, 50%) var(--mouse-y, 50%), black 0%, transparent 100%); mask-image: radial-gradient(circle 250px at var(--mouse-x, 50%) var(--mouse-y, 50%), black 0%, transparent 100%);"></div>
<div class="py-20 sm:py-28 lg:py-32">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="mx-auto max-w-3xl text-center">
<!-- Minimal badge -->
<p class="text-sm font-medium text-teal-600 mb-4">Keep users informed during planned downtime</p>
<h1 class="text-4xl font-semibold tracking-tight text-gray-900 sm:text-5xl lg:text-6xl">
Scheduled maintenance, done right
</h1>
<p class="mt-6 text-lg leading-8 text-gray-600 max-w-2xl mx-auto">
Plan maintenance windows, notify subscribers automatically, and update your status page in real-time. Keep your users informed every step of the way.
</p>
<div class="mt-10 flex items-center justify-center gap-x-6">
<a href="/accounts/register"
class="rounded-lg bg-gray-900 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-800 transition-colors">
Get started
</a>
<a href="/enterprise/demo" class="text-sm font-semibold text-gray-900 hover:text-gray-600 transition-colors">
Request demo <span aria-hidden="true">&rarr;</span>
</a>
</div>
<!-- Subtle feature list -->
<div class="mt-12 flex flex-wrap items-center justify-center gap-x-8 gap-y-3 text-sm text-gray-500">
<span>Advance scheduling</span>
<span class="hidden sm:inline text-gray-300">|</span>
<span>Subscriber notifications</span>
<span class="hidden sm:inline text-gray-300">|</span>
<span>Status page integration</span>
<span class="hidden sm:inline text-gray-300">|</span>
<span>Custom states</span>
</div>
</div>
<!-- Hero visual - Scheduled Maintenance dashboard mockup -->
<div class="mt-16 sm:mt-20">
<div class="relative mx-auto max-w-5xl">
<div class="rounded-xl bg-gray-900/5 p-1.5 ring-1 ring-gray-900/10">
<!-- Maintenance Dashboard Mockup -->
<div class="rounded-lg bg-white shadow-lg overflow-hidden">
<!-- Header bar -->
<div class="bg-gray-50 px-6 py-3 flex items-center justify-between border-b border-gray-200">
<div class="flex items-center gap-3">
<div class="flex gap-1.5">
<div class="w-3 h-3 rounded-full bg-red-400"></div>
<div class="w-3 h-3 rounded-full bg-yellow-400"></div>
<div class="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-teal-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"></path>
</svg>
<span class="text-sm text-gray-700 font-semibold">Scheduled Maintenance</span>
</div>
</div>
<div class="flex items-center gap-3">
<button class="flex items-center gap-1.5 px-3 py-1.5 bg-teal-600 rounded-md text-xs font-medium text-white hover:bg-teal-700 transition-colors">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Schedule Maintenance
</button>
</div>
</div>
<!-- Dashboard content -->
<div class="p-5">
<!-- Top stats row -->
<div class="grid grid-cols-4 gap-3 mb-5">
<div class="bg-white rounded-lg p-3.5 border border-gray-200 shadow-sm">
<div class="flex items-center justify-between mb-2">
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider">Scheduled</div>
<div class="w-6 h-6 rounded-md bg-blue-50 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div class="text-xl font-bold text-gray-900">3</div>
<div class="flex items-center gap-1 mt-1">
<span class="text-[10px] text-blue-600 font-medium">Upcoming this week</span>
</div>
</div>
<div class="bg-white rounded-lg p-3.5 border border-gray-200 shadow-sm">
<div class="flex items-center justify-between mb-2">
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider">Ongoing</div>
<div class="w-6 h-6 rounded-md bg-teal-50 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.384-3.08A1.59 1.59 0 015 10.68V5.83c0-1.16 1.057-2 2.163-1.78l7.603 1.52A1.59 1.59 0 0116 7.16v5.06c0 .63-.37 1.2-.95 1.46l-3.63 1.5z" />
</svg>
</div>
</div>
<div class="text-xl font-bold text-gray-900">1</div>
<div class="flex items-center gap-1 mt-1">
<span class="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse"></span>
<span class="text-[10px] text-teal-600 font-medium">In progress</span>
</div>
</div>
<div class="bg-white rounded-lg p-3.5 border border-gray-200 shadow-sm">
<div class="flex items-center justify-between mb-2">
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider">Completed</div>
<div class="w-6 h-6 rounded-md bg-emerald-50 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div class="text-xl font-bold text-gray-900">12</div>
<div class="flex items-center gap-1 mt-1">
<span class="text-[10px] text-emerald-600 font-medium">This month</span>
</div>
</div>
<div class="bg-white rounded-lg p-3.5 border border-gray-200 shadow-sm">
<div class="flex items-center justify-between mb-2">
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider">Subscribers</div>
<div class="w-6 h-6 rounded-md bg-violet-50 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</div>
</div>
<div class="text-xl font-bold text-gray-900">847</div>
<div class="flex items-center gap-1 mt-1">
<span class="text-[10px] text-violet-600 font-medium">Auto-notified</span>
</div>
</div>
</div>
<!-- Maintenance events list -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
<div class="px-4 py-2.5 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
<span class="text-xs font-semibold text-gray-700">Upcoming & Ongoing Events</span>
<span class="text-[10px] text-gray-400">4 events</span>
</div>
<div class="divide-y divide-gray-100">
<!-- Ongoing event -->
<div class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-2 h-8 rounded-full bg-teal-500"></div>
<div>
<div class="text-xs font-medium text-gray-900">Database Migration - Production</div>
<div class="text-[10px] text-gray-500 mt-0.5">Started 45 min ago &middot; Est. 2 hours remaining</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="flex items-center gap-1 px-2 py-1 bg-teal-50 rounded text-[10px] text-teal-700 border border-teal-200 font-medium">
<span class="w-1.5 h-1.5 rounded-full bg-teal-500 animate-pulse"></span>
Ongoing
</span>
</div>
</div>
<!-- Scheduled event 1 -->
<div class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-2 h-8 rounded-full bg-blue-400"></div>
<div>
<div class="text-xs font-medium text-gray-900">SSL Certificate Renewal</div>
<div class="text-[10px] text-gray-500 mt-0.5">Tomorrow, 2:00 AM &ndash; 2:30 AM UTC</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="px-2 py-1 bg-blue-50 rounded text-[10px] text-blue-700 border border-blue-200 font-medium">Scheduled</span>
</div>
</div>
<!-- Scheduled event 2 -->
<div class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-2 h-8 rounded-full bg-blue-400"></div>
<div>
<div class="text-xs font-medium text-gray-900">Infrastructure Upgrade - Load Balancers</div>
<div class="text-[10px] text-gray-500 mt-0.5">Sat, Mar 22 &middot; 1:00 AM &ndash; 5:00 AM UTC</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="px-2 py-1 bg-blue-50 rounded text-[10px] text-blue-700 border border-blue-200 font-medium">Scheduled</span>
</div>
</div>
<!-- Scheduled event 3 -->
<div class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-2 h-8 rounded-full bg-blue-400"></div>
<div>
<div class="text-xs font-medium text-gray-900">API Gateway Version Upgrade</div>
<div class="text-[10px] text-gray-500 mt-0.5">Mon, Mar 24 &middot; 3:00 AM &ndash; 4:00 AM UTC</div>
</div>
</div>
<div class="flex items-center gap-2">
<span class="px-2 py-1 bg-blue-50 rounded text-[10px] text-blue-700 border border-blue-200 font-medium">Scheduled</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('logo-roll') -%>
<!-- How It Works Section -->
<div class="relative bg-gray-50 py-24 sm:py-32">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="mx-auto max-w-2xl text-center mb-16">
<p class="text-sm font-medium text-teal-600 uppercase tracking-wide mb-3">How It Works</p>
<h2 class="text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl">
From planning to completion in four steps
</h2>
<p class="mt-4 text-lg text-gray-600">
Schedule maintenance windows, keep users informed, and complete your work without surprises.
</p>
</div>
<div class="mx-auto max-w-5xl">
<!-- Connecting line for desktop -->
<div class="hidden lg:block relative">
<div class="absolute top-8 left-[calc(12.5%+24px)] right-[calc(12.5%+24px)] h-px bg-teal-200"></div>
</div>
<div class="grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
<!-- Step 1 -->
<div class="text-center">
<div class="relative inline-flex items-center justify-center h-16 w-16 rounded-full bg-teal-600 text-white mb-6 shadow-sm">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
<span class="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-white text-xs font-semibold text-teal-600 ring-2 ring-teal-600">1</span>
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">Schedule Event</h3>
<p class="text-sm text-gray-600 leading-relaxed">Create a maintenance event with start time, duration, and affected services.</p>
</div>
<!-- Step 2 -->
<div class="text-center">
<div class="relative inline-flex items-center justify-center h-16 w-16 rounded-full bg-teal-600 text-white mb-6 shadow-sm">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
<span class="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-white text-xs font-semibold text-teal-600 ring-2 ring-teal-600">2</span>
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">Notify Subscribers</h3>
<p class="text-sm text-gray-600 leading-relaxed">Automatically notify subscribers via email, SMS, and webhooks before maintenance begins.</p>
</div>
<!-- Step 3 -->
<div class="text-center">
<div class="relative inline-flex items-center justify-center h-16 w-16 rounded-full bg-teal-600 text-white mb-6 shadow-sm">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.42 15.17l-5.384-3.08A1.59 1.59 0 015 10.68V5.83c0-1.16 1.057-2 2.163-1.78l7.603 1.52A1.59 1.59 0 0116 7.16v5.06c0 .63-.37 1.2-.95 1.46l-3.63 1.5z" />
</svg>
<span class="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-white text-xs font-semibold text-teal-600 ring-2 ring-teal-600">3</span>
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">Post Updates</h3>
<p class="text-sm text-gray-600 leading-relaxed">Share real-time progress updates on your status page as work proceeds.</p>
</div>
<!-- Step 4 -->
<div class="text-center">
<div class="relative inline-flex items-center justify-center h-16 w-16 rounded-full bg-teal-600 text-white mb-6 shadow-sm">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-white text-xs font-semibold text-teal-600 ring-2 ring-teal-600">4</span>
</div>
<h3 class="text-base font-semibold text-gray-900 mb-2">Complete & Resolve</h3>
<p class="text-sm text-gray-600 leading-relaxed">Mark maintenance complete and automatically notify subscribers that services are restored.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Why OneUptime Section -->
<div class="relative bg-white py-24 sm:py-32 overflow-hidden">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<!-- Section header -->
<div class="mx-auto max-w-2xl text-center mb-20">
<div class="inline-flex items-center gap-2 rounded-full bg-teal-50 px-4 py-1.5 text-sm font-medium text-teal-700 ring-1 ring-inset ring-teal-600/20 mb-6">
<svg class="h-4 w-4 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
<span class="text-sm font-medium text-teal-700">Why OneUptime for Scheduled Maintenance</span>
</div>
<h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl lg:text-5xl">
Maintenance communication your users will appreciate
</h2>
<p class="mt-6 text-lg leading-8 text-gray-600">
Proactive communication builds trust. Keep your users informed about planned downtime so they can plan accordingly.
</p>
</div>
<!-- Feature blocks -->
<div class="space-y-24">
<!-- Feature 1: Schedule & Plan -->
<div class="lg:grid lg:grid-cols-2 lg:gap-16 lg:items-center">
<div class="relative">
<div class="flex items-center gap-4 mb-6">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-50 ring-1 ring-teal-200">
<svg class="h-5 w-5 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
</div>
<span class="text-sm font-semibold text-teal-600 uppercase tracking-wide">Planning</span>
</div>
<h3 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl">Schedule maintenance with precision</h3>
<p class="mt-4 text-lg text-gray-600">Define start and end times, select affected monitors and status pages, and set custom maintenance states to communicate exactly what is happening.</p>
<ul class="mt-6 space-y-3">
<li class="flex items-center gap-3 text-gray-600">
<svg class="h-5 w-5 text-teal-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Set precise start and end times
</li>
<li class="flex items-center gap-3 text-gray-600">
<svg class="h-5 w-5 text-teal-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Assign affected monitors and services
</li>
<li class="flex items-center gap-3 text-gray-600">
<svg class="h-5 w-5 text-teal-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Custom maintenance states and labels
</li>
</ul>
<a href="/accounts/register" class="inline-flex items-center gap-1.5 px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors mt-8">
Get started
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
</div>
<div class="mt-12 lg:mt-0">
<div class="relative">
<div class="absolute -inset-4 bg-gray-100/50 rounded-3xl blur-2xl"></div>
<!-- Schedule Event Mockup -->
<div class="relative max-w-md mx-auto">
<div class="bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200">
<div class="bg-gray-50 px-4 py-2.5 flex items-center gap-3 border-b border-gray-100">
<div class="flex gap-1.5">
<div class="w-2.5 h-2.5 rounded-full bg-gray-300"></div>
<div class="w-2.5 h-2.5 rounded-full bg-gray-300"></div>
<div class="w-2.5 h-2.5 rounded-full bg-gray-300"></div>
</div>
<div class="flex-1 text-center">
<span class="text-xs text-gray-400">Create Maintenance Event</span>
</div>
</div>
<div class="p-4 space-y-3">
<div>
<label class="text-[10px] font-medium text-gray-500 uppercase tracking-wider">Event Title</label>
<div class="mt-1 px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-900 bg-white">Database Migration - Production</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[10px] font-medium text-gray-500 uppercase tracking-wider">Start Time</label>
<div class="mt-1 px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-900 bg-white">Mar 20, 2:00 AM</div>
</div>
<div>
<label class="text-[10px] font-medium text-gray-500 uppercase tracking-wider">End Time</label>
<div class="mt-1 px-3 py-2 border border-gray-200 rounded-lg text-sm text-gray-900 bg-white">Mar 20, 6:00 AM</div>
</div>
</div>
<div>
<label class="text-[10px] font-medium text-gray-500 uppercase tracking-wider">Affected Monitors</label>
<div class="mt-1 flex flex-wrap gap-1.5">
<span class="px-2 py-1 bg-teal-50 rounded text-[10px] text-teal-700 border border-teal-200">API Server</span>
<span class="px-2 py-1 bg-teal-50 rounded text-[10px] text-teal-700 border border-teal-200">Database</span>
<span class="px-2 py-1 bg-teal-50 rounded text-[10px] text-teal-700 border border-teal-200">Web App</span>
</div>
</div>
<div>
<label class="text-[10px] font-medium text-gray-500 uppercase tracking-wider">Status Pages</label>
<div class="mt-1 flex flex-wrap gap-1.5">
<span class="px-2 py-1 bg-blue-50 rounded text-[10px] text-blue-700 border border-blue-200">Public Status Page</span>
<span class="px-2 py-1 bg-blue-50 rounded text-[10px] text-blue-700 border border-blue-200">Internal Status</span>
</div>
</div>
<button class="w-full mt-2 py-2 bg-teal-600 text-white text-sm font-medium rounded-lg hover:bg-teal-700 transition-colors">Create Event</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Feature 2: Automatic Notifications -->
<div class="lg:grid lg:grid-cols-2 lg:gap-16 lg:items-center">
<div class="order-2 lg:order-1 mt-12 lg:mt-0">
<div class="relative">
<div class="absolute -inset-4 bg-gray-100/50 rounded-3xl blur-2xl"></div>
<!-- Notification Mockup -->
<div class="relative bg-white rounded-xl shadow-lg overflow-hidden max-w-md mx-auto border border-gray-200">
<div class="bg-gray-50 px-4 py-2.5 flex items-center gap-3 border-b border-gray-100">
<div class="flex gap-1.5">
<div class="w-2.5 h-2.5 rounded-full bg-gray-300"></div>
<div class="w-2.5 h-2.5 rounded-full bg-gray-300"></div>
<div class="w-2.5 h-2.5 rounded-full bg-gray-300"></div>
</div>
<div class="flex-1 text-center">
<span class="text-xs text-gray-400">Subscriber Notifications</span>
</div>
</div>
<div class="p-4 space-y-3">
<!-- Email notification -->
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 rounded bg-blue-100 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
</svg>
</div>
<div class="text-xs font-medium text-gray-700">Email</div>
<span class="ml-auto text-[10px] text-emerald-600 font-medium">847 sent</span>
</div>
<div class="text-[10px] text-gray-500">Scheduled maintenance notification sent to all subscribers</div>
</div>
<!-- SMS notification -->
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 rounded bg-emerald-100 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
</div>
<div class="text-xs font-medium text-gray-700">SMS</div>
<span class="ml-auto text-[10px] text-emerald-600 font-medium">123 sent</span>
</div>
<div class="text-[10px] text-gray-500">SMS alerts sent to phone subscribers</div>
</div>
<!-- Webhook notification -->
<div class="p-3 bg-gray-50 rounded-lg border border-gray-200">
<div class="flex items-center gap-2 mb-2">
<div class="w-6 h-6 rounded bg-violet-100 flex items-center justify-center">
<svg class="w-3.5 h-3.5 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
</div>
<div class="text-xs font-medium text-gray-700">Webhooks</div>
<span class="ml-auto text-[10px] text-emerald-600 font-medium">5 triggered</span>
</div>
<div class="text-[10px] text-gray-500">Webhook payloads sent to integrations</div>
</div>
</div>
</div>
</div>
</div>
<div class="order-1 lg:order-2">
<div class="flex items-center gap-4 mb-6">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-50 ring-1 ring-teal-200">
<svg class="h-5 w-5 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
</div>
<span class="text-sm font-semibold text-teal-600 uppercase tracking-wide">Notifications</span>
</div>
<h3 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl">Automatic subscriber notifications</h3>
<p class="mt-4 text-lg text-gray-600">Notify your users before maintenance begins via email, SMS, and webhooks. Send updates during maintenance and a final notification when services are restored.</p>
<ul class="mt-6 space-y-3">
<li class="flex items-center gap-3 text-gray-600">
<svg class="h-5 w-5 text-teal-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Email, SMS, and webhook notifications
</li>
<li class="flex items-center gap-3 text-gray-600">
<svg class="h-5 w-5 text-teal-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Pre-maintenance advance notices
</li>
<li class="flex items-center gap-3 text-gray-600">
<svg class="h-5 w-5 text-teal-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Completion notifications when resolved
</li>
</ul>
<a href="/accounts/register" class="inline-flex items-center gap-1.5 px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors mt-8">
Get started
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
</div>
</div>
<!-- Feature 3: Status Page Integration -->
<div class="lg:grid lg:grid-cols-2 lg:gap-16 lg:items-center">
<div class="relative">
<div class="flex items-center gap-4 mb-6">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-teal-50 ring-1 ring-teal-200">
<svg class="h-5 w-5 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
</svg>
</div>
<span class="text-sm font-semibold text-teal-600 uppercase tracking-wide">Status Page</span>
</div>
<h3 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-3xl">Seamless status page integration</h3>
<p class="mt-4 text-lg text-gray-600">Scheduled maintenance events automatically appear on your status page, keeping your users informed with a timeline of upcoming and ongoing maintenance.</p>
<ul class="mt-6 space-y-3">
<li class="flex items-center gap-3 text-gray-600">
<svg class="h-5 w-5 text-teal-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Automatic status page updates
</li>
<li class="flex items-center gap-3 text-gray-600">
<svg class="h-5 w-5 text-teal-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Public maintenance timeline
</li>
<li class="flex items-center gap-3 text-gray-600">
<svg class="h-5 w-5 text-teal-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Real-time progress notes
</li>
</ul>
<a href="/accounts/register" class="inline-flex items-center gap-1.5 px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors mt-8">
Get started
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</a>
</div>
<div class="mt-12 lg:mt-0">
<div class="relative">
<div class="absolute -inset-4 bg-gray-100/50 rounded-3xl blur-2xl"></div>
<!-- Status Page Mockup -->
<div class="relative max-w-md mx-auto">
<div class="bg-white rounded-xl shadow-lg overflow-hidden border border-gray-200">
<div class="bg-gray-50 px-4 py-2.5 flex items-center gap-3 border-b border-gray-100">
<div class="flex gap-1.5">
<div class="w-2.5 h-2.5 rounded-full bg-gray-300"></div>
<div class="w-2.5 h-2.5 rounded-full bg-gray-300"></div>
<div class="w-2.5 h-2.5 rounded-full bg-gray-300"></div>
</div>
<div class="flex-1 text-center">
<span class="text-xs text-gray-400">Status Page - Maintenance</span>
</div>
</div>
<div class="p-4">
<div class="flex items-center gap-2 mb-4">
<svg class="h-5 w-5 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17l-5.384-3.08A1.59 1.59 0 015 10.68V5.83c0-1.16 1.057-2 2.163-1.78l7.603 1.52A1.59 1.59 0 0116 7.16v5.06c0 .63-.37 1.2-.95 1.46l-3.63 1.5z" />
</svg>
<span class="text-sm font-semibold text-gray-900">Scheduled Maintenance</span>
</div>
<!-- Timeline -->
<div class="space-y-4">
<div class="relative pl-6 border-l-2 border-teal-200">
<div class="absolute -left-[5px] top-1 w-2 h-2 rounded-full bg-teal-500"></div>
<div class="text-[10px] text-teal-600 font-medium">In Progress</div>
<div class="text-xs font-medium text-gray-900 mt-1">Database Migration - Production</div>
<div class="text-[10px] text-gray-500 mt-0.5">Migration is 75% complete. No user impact detected.</div>
<div class="text-[10px] text-gray-400 mt-1">Updated 15 minutes ago</div>
</div>
<div class="relative pl-6 border-l-2 border-teal-200">
<div class="absolute -left-[5px] top-1 w-2 h-2 rounded-full bg-teal-400"></div>
<div class="text-[10px] text-teal-600 font-medium">Started</div>
<div class="text-xs font-medium text-gray-900 mt-1">Maintenance window opened</div>
<div class="text-[10px] text-gray-500 mt-0.5">Beginning database migration. Expected duration: 4 hours.</div>
<div class="text-[10px] text-gray-400 mt-1">2:00 AM UTC</div>
</div>
<div class="relative pl-6 border-l-2 border-gray-200">
<div class="absolute -left-[5px] top-1 w-2 h-2 rounded-full bg-gray-300"></div>
<div class="text-[10px] text-gray-500 font-medium">Scheduled</div>
<div class="text-xs font-medium text-gray-900 mt-1">Maintenance announced</div>
<div class="text-[10px] text-gray-500 mt-0.5">Subscribers notified about upcoming maintenance.</div>
<div class="text-[10px] text-gray-400 mt-1">Mar 18, 10:00 AM UTC</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Workspace Integration Section -->
<div class="bg-gray-50 py-24 sm:py-32">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="mx-auto max-w-2xl text-center mb-16">
<p class="text-sm font-medium text-teal-600 uppercase tracking-wide mb-3">Integrations</p>
<h2 class="text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl">
Works with your existing tools
</h2>
<p class="mt-4 text-lg text-gray-600">
Connect scheduled maintenance events to Slack, Microsoft Teams, and more. Keep your entire team in the loop.
</p>
</div>
<div class="mx-auto max-w-4xl grid grid-cols-1 gap-8 sm:grid-cols-3">
<!-- Slack -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-200 text-center">
<div class="inline-flex items-center justify-center w-12 h-12 bg-gray-50 rounded-xl mb-4 ring-1 ring-gray-200">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" fill="#E01E5A"/>
<path d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" fill="#36C5F0"/>
<path d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312z" fill="#2EB67D"/>
<path d="M15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" fill="#ECB22E"/>
</svg>
</div>
<h3 class="text-sm font-semibold text-gray-900 mb-1">Slack</h3>
<p class="text-xs text-gray-500">Post maintenance updates directly to Slack channels</p>
</div>
<!-- Microsoft Teams -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-200 text-center">
<div class="inline-flex items-center justify-center w-12 h-12 bg-gray-50 rounded-xl mb-4 ring-1 ring-gray-200">
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none">
<path d="M20.16 7.8h-4.736c.103.317.159.654.159 1.005v7.029a3.588 3.588 0 01-3.583 3.583h-4.8v.777c0 .998.81 1.806 1.806 1.806h8.339l2.869 1.912a.452.452 0 00.703-.376V21.2h.646c.997 0 1.806-.809 1.806-1.806v-9.788A1.806 1.806 0 0020.16 7.8z" fill="#5059C9"/>
<circle cx="18.5" cy="4.5" r="2.5" fill="#5059C9"/>
<path d="M13.5 6H3.806A1.806 1.806 0 002 7.806v9.388c0 .997.81 1.806 1.806 1.806h2.611v3.336a.452.452 0 00.703.376L10.5 20h3a3.5 3.5 0 003.5-3.5v-7A3.5 3.5 0 0013.5 6z" fill="#7B83EB"/>
<circle cx="10" cy="3" r="3" fill="#7B83EB"/>
<path d="M6 11h6M6 14h4" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<h3 class="text-sm font-semibold text-gray-900 mb-1">Microsoft Teams</h3>
<p class="text-xs text-gray-500">Send maintenance alerts to Teams channels</p>
</div>
<!-- Webhooks -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-200 text-center">
<div class="inline-flex items-center justify-center w-12 h-12 bg-gray-50 rounded-xl mb-4 ring-1 ring-gray-200">
<svg class="w-6 h-6 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
</div>
<h3 class="text-sm font-semibold text-gray-900 mb-1">Webhooks</h3>
<p class="text-xs text-gray-500">Trigger custom workflows with webhook events</p>
</div>
</div>
</div>
</div>
<%- include('./Partials/enterprise-ready') -%>
<%- include('features-table') -%>
<%- include('cta') -%>
</main>
<%- include('footer') -%>
<!-- Grid glow effect script -->
<script>
(function() {
const heroSection = document.getElementById('maintenance-hero-section');
const gridGlow = document.getElementById('maintenance-grid-glow');
if (heroSection && gridGlow) {
heroSection.addEventListener('mousemove', function(e) {
const rect = heroSection.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
gridGlow.style.setProperty('--mouse-x', x + 'px');
gridGlow.style.setProperty('--mouse-y', y + 'px');
gridGlow.style.opacity = '1';
});
heroSection.addEventListener('mouseleave', function() {
gridGlow.style.opacity = '0';
});
}
})();
</script>
</body>
</html>