mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
100
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobs.tsx
Normal file
100
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/CronJobs.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
274
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx
Normal file
274
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/JobDetail.tsx
Normal 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;
|
||||
100
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Jobs.tsx
Normal file
100
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Jobs.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
31
Common/Server/API/IPWhitelistAPI.ts
Normal file
31
Common/Server/API/IPWhitelistAPI.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" },
|
||||
|
||||
10
Home/Views/Partials/hero-cards/scheduled-maintenance.ejs
Normal file
10
Home/Views/Partials/hero-cards/scheduled-maintenance.ejs
Normal 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 & communicate downtime</div>
|
||||
</a>
|
||||
</div>
|
||||
12
Home/Views/Partials/icons/kubernetes.ejs
Normal file
12
Home/Views/Partials/icons/kubernetes.ejs
Normal 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 |
4
Home/Views/Partials/icons/scheduled-maintenance.ejs
Normal file
4
Home/Views/Partials/icons/scheduled-maintenance.ejs
Normal 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
1297
Home/Views/kubernetes.ejs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
708
Home/Views/scheduled-maintenance.ejs
Normal file
708
Home/Views/scheduled-maintenance.ejs
Normal 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">→</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 · 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 – 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 · 1:00 AM – 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 · 3:00 AM – 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>
|
||||
Reference in New Issue
Block a user