diff --git a/.claude/launch.json b/.claude/launch.json index bbc9511b78..e49adee269 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,13 @@ "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 } ] } diff --git a/.github/workflows/common-jobs.yaml b/.github/workflows/common-jobs.yaml index b174b5914d..c6d55a55f6 100644 --- a/.github/workflows/common-jobs.yaml +++ b/.github/workflows/common-jobs.yaml @@ -18,9 +18,10 @@ jobs: - name: Install Helm run: | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash - - name: Lint Helm Chart + - name: Lint Helm Chart run: | helm lint ./HelmChart/Public/oneuptime + helm lint ./HelmChart/Public/kubernetes-agent js-lint: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fff4c3295..7c3444c032 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,11 +134,16 @@ jobs: helm lint oneuptime helm template oneuptime --values oneuptime/values.yaml helm package --sign --key 'key@oneuptime.com' --keyring ~/.gnupg/secring.gpg oneuptime --version ${{needs.read-version.outputs.major_minor}} --app-version ${{needs.read-version.outputs.major_minor}} - echo "Helm Chart Package created successfully" + echo "OneUptime Helm Chart Package created successfully" + helm lint kubernetes-agent + helm template kubernetes-agent --values kubernetes-agent/values.yaml + helm package --sign --key 'key@oneuptime.com' --keyring ~/.gnupg/secring.gpg kubernetes-agent --version ${{needs.read-version.outputs.major_minor}} --app-version ${{needs.read-version.outputs.major_minor}} + echo "Kubernetes Agent Helm Chart Package created successfully" cd .. ls echo "Copying the package to helm-chart repo" - rm -r ../../helm-chart/oneuptime + rm -rf ../../helm-chart/oneuptime + rm -rf ../../helm-chart/kubernetes-agent cp -r ./Public/* ../../helm-chart echo "Package copied successfully" cd .. && cd .. && cd helm-chart diff --git a/App/FeatureSet/BaseAPI/Index.ts b/App/FeatureSet/BaseAPI/Index.ts index 8d3c43327f..9d4aca34a3 100644 --- a/App/FeatureSet/BaseAPI/Index.ts +++ b/App/FeatureSet/BaseAPI/Index.ts @@ -233,6 +233,9 @@ import IncidentTemplateOwnerUserService, { import IncidentTemplateService, { Service as IncidentTemplateServiceType, } from "Common/Server/Services/IncidentTemplateService"; +import KubernetesClusterService, { + Service as KubernetesClusterServiceType, +} from "Common/Server/Services/KubernetesClusterService"; import LabelService, { Service as LabelServiceType, } from "Common/Server/Services/LabelService"; @@ -552,6 +555,7 @@ import IncidentTemplate from "Common/Models/DatabaseModels/IncidentTemplate"; import IncidentTemplateOwnerTeam from "Common/Models/DatabaseModels/IncidentTemplateOwnerTeam"; import IncidentTemplateOwnerUser from "Common/Models/DatabaseModels/IncidentTemplateOwnerUser"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; import Label from "Common/Models/DatabaseModels/Label"; import MonitorCustomField from "Common/Models/DatabaseModels/MonitorCustomField"; import MonitorGroupOwnerTeam from "Common/Models/DatabaseModels/MonitorGroupOwnerTeam"; @@ -1859,6 +1863,14 @@ const BaseAPIFeatureSet: FeatureSet = { new BaseAPI(Label, LabelService).getRouter(), ); + app.use( + `/${APP_NAME.toLocaleLowerCase()}`, + new BaseAPI( + KubernetesCluster, + KubernetesClusterService, + ).getRouter(), + ); + app.use( `/${APP_NAME.toLocaleLowerCase()}`, new BaseAPI( diff --git a/App/FeatureSet/Dashboard/src/App.tsx b/App/FeatureSet/Dashboard/src/App.tsx index 14f6d057c6..67a3dd5a3a 100644 --- a/App/FeatureSet/Dashboard/src/App.tsx +++ b/App/FeatureSet/Dashboard/src/App.tsx @@ -181,6 +181,15 @@ const ServiceRoutes: React.LazyExoticComponent< }; }); }); +const KubernetesRoutes: React.LazyExoticComponent< + AllRoutesModule["KubernetesRoutes"] +> = lazy(() => { + return import("./Routes/AllRoutes").then((m: AllRoutesModule) => { + return { + default: m.KubernetesRoutes, + }; + }); +}); const CodeRepositoryRoutes: React.LazyExoticComponent< AllRoutesModule["CodeRepositoryRoutes"] > = lazy(() => { @@ -528,6 +537,12 @@ const App: () => JSX.Element = () => { element={} /> + {/* Kubernetes */} + } + /> + {/* Code Repository */} ; title: string; description: string; + onFetchSuccess?: + | ((data: Array, totalCount: number) => void) + | undefined; } const TelemetryExceptionTable: FunctionComponent = ( @@ -47,6 +50,7 @@ const TelemetryExceptionTable: FunctionComponent = ( userPreferencesKey="telemetry-exception-table" isEditable={false} isCreateable={false} + onFetchSuccess={props.onFetchSuccess} singularName="Exception" pluralName="Exceptions" name="TelemetryException" diff --git a/App/FeatureSet/Dashboard/src/Components/Kubernetes/DocumentationCard.tsx b/App/FeatureSet/Dashboard/src/Components/Kubernetes/DocumentationCard.tsx new file mode 100644 index 0000000000..c781e22020 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Components/Kubernetes/DocumentationCard.tsx @@ -0,0 +1,313 @@ +import React, { + FunctionComponent, + ReactElement, + useEffect, + useMemo, + useState, +} from "react"; +import TelemetryIngestionKey from "Common/Models/DatabaseModels/TelemetryIngestionKey"; +import ModelAPI, { ListResult } from "Common/UI/Utils/ModelAPI/ModelAPI"; +import ProjectUtil from "Common/UI/Utils/Project"; +import { HOST, HTTP_PROTOCOL } from "Common/UI/Config"; +import ModelFormModal from "Common/UI/Components/ModelFormModal/ModelFormModal"; +import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType"; +import { FormType } from "Common/UI/Components/Forms/ModelForm"; +import API from "Common/UI/Utils/API/API"; +import IconProp from "Common/Types/Icon/IconProp"; +import Icon from "Common/UI/Components/Icon/Icon"; +import Dropdown, { + DropdownOption, + DropdownValue, +} from "Common/UI/Components/Dropdown/Dropdown"; +import Protocol from "Common/Types/API/Protocol"; +import Card from "Common/UI/Components/Card/Card"; +import MarkdownViewer from "Common/UI/Components/Markdown.tsx/MarkdownViewer"; +import { getKubernetesInstallationMarkdown } from "../../Pages/Kubernetes/Utils/DocumentationMarkdown"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; + +export interface ComponentProps { + clusterName: string; + title: string; + description: string; +} + +const KubernetesDocumentationCard: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + // Ingestion key state + const [ingestionKeys, setIngestionKeys] = useState< + Array + >([]); + const [selectedKeyId, setSelectedKeyId] = useState(""); + const [isLoadingKeys, setIsLoadingKeys] = useState(true); + const [showCreateModal, setShowCreateModal] = useState(false); + const [keyError, setKeyError] = useState(""); + + // Compute OneUptime URL + const httpProtocol: string = + HTTP_PROTOCOL === Protocol.HTTPS ? "https" : "http"; + const oneuptimeUrl: string = HOST + ? `${httpProtocol}://${HOST}` + : ""; + + // Fetch ingestion keys on mount + useEffect(() => { + loadIngestionKeys().catch(() => {}); + }, []); + + const loadIngestionKeys: () => Promise = async (): Promise => { + try { + setIsLoadingKeys(true); + setKeyError(""); + const result: ListResult = + await ModelAPI.getList({ + modelType: TelemetryIngestionKey, + query: { + projectId: ProjectUtil.getCurrentProjectId()!, + }, + limit: 50, + skip: 0, + select: { + _id: true, + name: true, + secretKey: true, + description: true, + }, + sort: {}, + }); + + setIngestionKeys(result.data); + + // Auto-select the first key if available and none selected + if (result.data.length > 0 && !selectedKeyId) { + setSelectedKeyId(result.data[0]!.id?.toString() || ""); + } + } catch (err) { + setKeyError(API.getFriendlyErrorMessage(err as Error)); + } finally { + setIsLoadingKeys(false); + } + }; + + // Get the selected key object + const selectedKey: TelemetryIngestionKey | undefined = useMemo(() => { + return ingestionKeys.find((k: TelemetryIngestionKey) => { + return k.id?.toString() === selectedKeyId; + }); + }, [ingestionKeys, selectedKeyId]); + + // Get API key for code snippets + const apiKeyValue: string = + selectedKey?.secretKey?.toString() || ""; + + const renderKeySelector: () => ReactElement = (): ReactElement => { + if (isLoadingKeys) { + return ; + } + + if (keyError) { + return ; + } + + if (ingestionKeys.length === 0) { + return ( +
+

+ No ingestion keys yet +

+

+ Create an ingestion key to authenticate your Kubernetes agent. +

+ +
+ ); + } + + return ( +
+ {/* Key selector row */} +
+
+ { + return { + value: key.id?.toString() || "", + label: key.name || "Unnamed Key", + }; + }, + )} + value={ + ingestionKeys + .filter((key: TelemetryIngestionKey) => { + return key.id?.toString() === selectedKeyId; + }) + .map((key: TelemetryIngestionKey): DropdownOption => { + return { + value: key.id?.toString() || "", + label: key.name || "Unnamed Key", + }; + })[0] + } + onChange={( + value: DropdownValue | Array | null, + ) => { + if (value) { + setSelectedKeyId(value.toString()); + } + }} + placeholder="Select an ingestion key" + ariaLabel="Select ingestion key" + /> +
+ +
+ + {/* Credentials display */} + {selectedKey && ( +
+
+
+
+ +
+
+
+ OneUptime URL +
+
+ {oneuptimeUrl} +
+
+
+
+
+ +
+
+
+ API Key +
+
+ {selectedKey.secretKey?.toString() || "—"} +
+
+
+
+
+ )} +
+ ); + }; + + const installationMarkdown: string = getKubernetesInstallationMarkdown({ + clusterName: props.clusterName, + oneuptimeUrl: oneuptimeUrl, + apiKey: apiKeyValue, + }); + + return ( +
+ +
+ {/* Ingestion Key Section */} +
+ + {renderKeySelector()} +
+ + {/* Documentation */} + +
+
+ + {/* Create Ingestion Key Modal */} + {showCreateModal && ( + + modelType={TelemetryIngestionKey} + name="Create Ingestion Key" + title="Create Ingestion Key" + description="Create a new telemetry ingestion key for authenticating your Kubernetes agent." + onClose={() => { + setShowCreateModal(false); + }} + submitButtonText="Create Key" + onSuccess={(item: TelemetryIngestionKey) => { + setShowCreateModal(false); + loadIngestionKeys() + .then(() => { + if (item.id) { + setSelectedKeyId(item.id.toString()); + } + }) + .catch(() => {}); + }} + formProps={{ + name: "Create Ingestion Key", + modelType: TelemetryIngestionKey, + id: "create-ingestion-key", + fields: [ + { + field: { + name: true, + }, + title: "Name", + fieldType: FormFieldSchemaType.Text, + required: true, + placeholder: "e.g. Kubernetes Agent Key", + validation: { + minLength: 2, + }, + }, + { + field: { + description: true, + }, + title: "Description", + fieldType: FormFieldSchemaType.LongText, + required: false, + placeholder: "Optional description for this key", + }, + ], + formType: FormType.Create, + }} + onBeforeCreate={( + item: TelemetryIngestionKey, + ): Promise => { + item.projectId = ProjectUtil.getCurrentProjectId()!; + return Promise.resolve(item); + }} + /> + )} +
+ ); +}; + +export default KubernetesDocumentationCard; diff --git a/App/FeatureSet/Dashboard/src/Components/Logs/LogsViewer.tsx b/App/FeatureSet/Dashboard/src/Components/Logs/LogsViewer.tsx index 07d5921a36..7a1b7a1438 100644 --- a/App/FeatureSet/Dashboard/src/Components/Logs/LogsViewer.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Logs/LogsViewer.tsx @@ -70,6 +70,8 @@ export interface ComponentProps { noLogsMessage?: string | undefined; logQuery?: Query | undefined; limit?: number | undefined; + onCountChange?: ((count: number) => void) | undefined; + onShowDocumentation?: (() => void) | undefined; } const DEFAULT_PAGE_SIZE: number = 100; @@ -427,6 +429,10 @@ const DashboardLogsViewer: FunctionComponent = ( setLogs(listResult.data); setTotalCount(listResult.count); + if (props.onCountChange) { + props.onCountChange(listResult.count); + } + const maximumPage: number = Math.max( 1, Math.ceil(listResult.count / Math.max(pageSize, 1)), @@ -1236,6 +1242,7 @@ const DashboardLogsViewer: FunctionComponent = ( onFieldValueSelect={handleFieldValueSelect} timeRange={timeRange} onTimeRangeChange={handleTimeRangeChange} + onShowDocumentation={props.onShowDocumentation} selectedColumns={selectedColumns} onSelectedColumnsChange={(columns: Array) => { setSelectedColumns(normalizeLogsTableColumns(columns)); diff --git a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricsTable.tsx b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricsTable.tsx index e9779d949b..e68cdff394 100644 --- a/App/FeatureSet/Dashboard/src/Components/Metrics/MetricsTable.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Metrics/MetricsTable.tsx @@ -17,6 +17,9 @@ import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType" export interface ComponentProps { serviceIds?: Array | undefined; + onFetchSuccess?: + | ((data: Array, totalCount: number) => void) + | undefined; } const MetricsTable: FunctionComponent = ( @@ -113,6 +116,7 @@ const MetricsTable: FunctionComponent = ( }} showViewIdButton={false} noItemsMessage={"No metrics found for this service."} + onFetchSuccess={props.onFetchSuccess} showRefreshButton={true} viewPageRoute={Navigation.getCurrentRoute()} filters={[ diff --git a/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx b/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx index 6ca8d7d15a..0079edc4ed 100644 --- a/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx +++ b/App/FeatureSet/Dashboard/src/Components/NavBar/NavBar.tsx @@ -144,6 +144,17 @@ const DashboardNavbar: FunctionComponent = ( 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", + // }, // Automation & Analytics { title: "Dashboards", diff --git a/App/FeatureSet/Dashboard/src/Components/Span/SpanViewer.tsx b/App/FeatureSet/Dashboard/src/Components/Span/SpanViewer.tsx index 31e7c5e5e5..8c7308a0ed 100644 --- a/App/FeatureSet/Dashboard/src/Components/Span/SpanViewer.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Span/SpanViewer.tsx @@ -622,8 +622,7 @@ const SpanViewer: FunctionComponent = ( - {link.attributes && - Object.keys(link.attributes).length > 0 ? ( + {link.attributes && Object.keys(link.attributes).length > 0 ? (
Attributes diff --git a/App/FeatureSet/Dashboard/src/Components/Telemetry/Documentation.tsx b/App/FeatureSet/Dashboard/src/Components/Telemetry/Documentation.tsx index 1b22f21bce..60ce6d2fed 100644 --- a/App/FeatureSet/Dashboard/src/Components/Telemetry/Documentation.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Telemetry/Documentation.tsx @@ -1,179 +1,1487 @@ -import CSharpImage from "../Images/SvgImages/csharp.svg"; -import DockerImage from "../Images/SvgImages/docker.svg"; -import GoImage from "../Images/SvgImages/go.svg"; -import JavaImage from "../Images/SvgImages/java.svg"; -import JavaScriptImage from "../Images/SvgImages/javascript.svg"; -import MoreSourcesImage from "../Images/SvgImages/moresources.svg"; -import MySQLImage from "../Images/SvgImages/mysql.svg"; -import NodeImage from "../Images/SvgImages/node.svg"; -import PostgresSQLImage from "../Images/SvgImages/postgres.svg"; -import PythonImage from "../Images/SvgImages/python.svg"; -import ReactImage from "../Images/SvgImages/react.svg"; -import RustImage from "../Images/SvgImages/rust.svg"; -import SyslogImage from "../Images/SvgImages/syslog.svg"; -import SystemdImage from "../Images/SvgImages/systemd.svg"; -import TypeScriptImage from "../Images/SvgImages/typescript.svg"; -import Route from "Common/Types/API/Route"; -import Card from "Common/UI/Components/Card/Card"; -import ImageTiles from "Common/UI/Components/ImageTiles/ImageTiles"; -import React, { FunctionComponent, ReactElement } from "react"; +import React, { + FunctionComponent, + ReactElement, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import CodeBlock from "Common/UI/Components/CodeBlock/CodeBlock"; +import TelemetryIngestionKey from "Common/Models/DatabaseModels/TelemetryIngestionKey"; +import ModelAPI, { ListResult } from "Common/UI/Utils/ModelAPI/ModelAPI"; +import ProjectUtil from "Common/UI/Utils/Project"; +import { HOST, HTTP_PROTOCOL } from "Common/UI/Config"; +import ModelFormModal from "Common/UI/Components/ModelFormModal/ModelFormModal"; +import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType"; +import { FormType } from "Common/UI/Components/Forms/ModelForm"; +import API from "Common/UI/Utils/API/API"; +import IconProp from "Common/Types/Icon/IconProp"; +import Icon from "Common/UI/Components/Icon/Icon"; +import Dropdown, { + DropdownOption, + DropdownValue, +} from "Common/UI/Components/Dropdown/Dropdown"; +import Protocol from "Common/Types/API/Protocol"; -const TelemetryDocumentation: FunctionComponent = (): ReactElement => { - const openTelemetryDocUrl: Route = Route.fromString( - "/docs/telemetry/open-telemetry", +export type TelemetryType = "logs" | "metrics" | "traces" | "exceptions"; + +export interface ComponentProps { + telemetryType?: TelemetryType | undefined; + onClose?: (() => void) | undefined; +} + +type Language = + | "node" + | "python" + | "go" + | "java" + | "dotnet" + | "rust" + | "php" + | "ruby" + | "elixir" + | "cpp" + | "swift" + | "react" + | "angular"; + +interface LanguageOption { + key: Language; + label: string; + shortLabel: string; +} + +const languages: Array = [ + { key: "node", label: "Node.js / TypeScript", shortLabel: "Node.js" }, + { key: "python", label: "Python", shortLabel: "Python" }, + { key: "go", label: "Go", shortLabel: "Go" }, + { key: "java", label: "Java", shortLabel: "Java" }, + { key: "dotnet", label: ".NET / C#", shortLabel: ".NET" }, + { key: "rust", label: "Rust", shortLabel: "Rust" }, + { key: "php", label: "PHP", shortLabel: "PHP" }, + { key: "ruby", label: "Ruby", shortLabel: "Ruby" }, + { key: "elixir", label: "Elixir", shortLabel: "Elixir" }, + { key: "cpp", label: "C++", shortLabel: "C++" }, + { key: "swift", label: "Swift", shortLabel: "Swift" }, + { key: "react", label: "React (Browser)", shortLabel: "React" }, + { key: "angular", label: "Angular (Browser)", shortLabel: "Angular" }, +]; + +type IntegrationMethod = "opentelemetry" | "fluentbit" | "fluentd"; + +interface IntegrationOption { + key: IntegrationMethod; + label: string; + description: string; +} + +// Helper to replace placeholders in code snippets +function replacePlaceholders( + code: string, + otlpUrl: string, + otlpHost: string, + token: string, +): string { + return code + .replace(//g, otlpUrl) + .replace(//g, otlpHost) + .replace(//g, token); +} + +// --- OpenTelemetry code snippets per language --- + +function getOtelInstallSnippet(lang: Language): { + code: string; + language: string; +} { + switch (lang) { + case "node": + return { + code: `npm install @opentelemetry/sdk-node \\ + @opentelemetry/auto-instrumentations-node \\ + @opentelemetry/exporter-trace-otlp-proto \\ + @opentelemetry/exporter-metrics-otlp-proto \\ + @opentelemetry/exporter-logs-otlp-proto`, + language: "bash", + }; + case "python": + return { + code: `pip install opentelemetry-api \\ + opentelemetry-sdk \\ + opentelemetry-exporter-otlp-proto-http \\ + opentelemetry-instrumentation`, + language: "bash", + }; + case "go": + return { + code: `go get go.opentelemetry.io/otel \\ + go.opentelemetry.io/otel/sdk \\ + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \\ + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp \\ + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp`, + language: "bash", + }; + case "java": + return { + code: `# Download the OpenTelemetry Java Agent +curl -L -o opentelemetry-javaagent.jar \\ + https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar`, + language: "bash", + }; + case "dotnet": + return { + code: `dotnet add package OpenTelemetry +dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol +dotnet add package OpenTelemetry.Extensions.Hosting +dotnet add package OpenTelemetry.Instrumentation.AspNetCore +dotnet add package OpenTelemetry.Instrumentation.Http`, + language: "bash", + }; + case "rust": + return { + code: `# Add to Cargo.toml +[dependencies] +opentelemetry = "0.22" +opentelemetry_sdk = { version = "0.22", features = ["rt-tokio"] } +opentelemetry-otlp = { version = "0.15", features = ["http-proto", "reqwest-client"] } +tracing = "0.1" +tracing-opentelemetry = "0.23" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +reqwest = { version = "0.11", features = ["blocking"] }`, + language: "bash", + }; + case "php": + return { + code: `composer require open-telemetry/sdk \\ + open-telemetry/exporter-otlp \\ + open-telemetry/transport-http`, + language: "bash", + }; + case "ruby": + return { + code: `# Add to Gemfile +gem 'opentelemetry-sdk' +gem 'opentelemetry-exporter-otlp' +gem 'opentelemetry-instrumentation-all' + +# Then run: +bundle install`, + language: "bash", + }; + case "elixir": + return { + code: `# Add to mix.exs deps +defp deps do + [ + {:opentelemetry, "~> 1.4"}, + {:opentelemetry_sdk, "~> 1.4"}, + {:opentelemetry_exporter, "~> 1.7"}, + {:opentelemetry_phoenix, "~> 1.2"}, + {:opentelemetry_ecto, "~> 1.2"} + ] +end + +# Then run: +mix deps.get`, + language: "bash", + }; + case "cpp": + return { + code: `# Using vcpkg +vcpkg install opentelemetry-cpp[otlp-http] + +# Or using CMake FetchContent: +# include(FetchContent) +# FetchContent_Declare(opentelemetry-cpp +# GIT_REPOSITORY https://github.com/open-telemetry/opentelemetry-cpp.git +# GIT_TAG v1.14.0) +# set(WITH_OTLP_HTTP ON) +# FetchContent_MakeAvailable(opentelemetry-cpp)`, + language: "bash", + }; + case "swift": + return { + code: `// Add to Package.swift dependencies: +.package(url: "https://github.com/open-telemetry/opentelemetry-swift.git", from: "1.9.0") + +// And add to target dependencies: +.product(name: "OpenTelemetryApi", package: "opentelemetry-swift"), +.product(name: "OpenTelemetrySdk", package: "opentelemetry-swift"), +.product(name: "OtlpHttpSpanExporting", package: "opentelemetry-swift"),`, + language: "bash", + }; + case "react": + return { + code: `npm install @opentelemetry/api \\ + @opentelemetry/sdk-trace-web \\ + @opentelemetry/sdk-trace-base \\ + @opentelemetry/exporter-trace-otlp-http \\ + @opentelemetry/instrumentation-document-load \\ + @opentelemetry/instrumentation-fetch \\ + @opentelemetry/instrumentation-xml-http-request \\ + @opentelemetry/context-zone`, + language: "bash", + }; + case "angular": + return { + code: `npm install @opentelemetry/api \\ + @opentelemetry/sdk-trace-web \\ + @opentelemetry/sdk-trace-base \\ + @opentelemetry/exporter-trace-otlp-http \\ + @opentelemetry/instrumentation-document-load \\ + @opentelemetry/instrumentation-fetch \\ + @opentelemetry/instrumentation-xml-http-request \\ + @opentelemetry/context-zone`, + language: "bash", + }; + } +} + +function getOtelConfigSnippet(lang: Language): { + code: string; + language: string; +} { + switch (lang) { + case "node": + return { + code: `// tracing.ts - Run with: node --require ./tracing.ts app.ts +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'; +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'; + +const sdk = new NodeSDK({ + serviceName: 'my-service', + traceExporter: new OTLPTraceExporter({ + url: '/v1/traces', + headers: { 'x-oneuptime-token': '' }, + }), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ + url: '/v1/metrics', + headers: { 'x-oneuptime-token': '' }, + }), + }), + logRecordProcessors: [ + new BatchLogRecordProcessor( + new OTLPLogExporter({ + url: '/v1/logs', + headers: { 'x-oneuptime-token': '' }, + }) + ), + ], + instrumentations: [getNodeAutoInstrumentations()], +}); + +sdk.start();`, + language: "typescript", + }; + case "python": + return { + code: `# tracing.py +from opentelemetry import trace, metrics +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter +from opentelemetry.sdk.resources import Resource + +resource = Resource.create({"service.name": "my-service"}) + +# Traces +trace_provider = TracerProvider(resource=resource) +trace_provider.add_span_processor( + BatchSpanProcessor( + OTLPSpanExporter( + endpoint="", + headers={"x-oneuptime-token": ""}, + ) + ) +) +trace.set_tracer_provider(trace_provider) + +# Metrics +metric_reader = PeriodicExportingMetricReader( + OTLPMetricExporter( + endpoint="", + headers={"x-oneuptime-token": ""}, + ) +) +metrics.set_meter_provider(MeterProvider(resource=resource, metric_readers=[metric_reader]))`, + language: "python", + }; + case "go": + return { + code: `package main + +import ( + "context" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" +) + +func initTracer() (*sdktrace.TracerProvider, error) { + ctx := context.Background() + + exporter, err := otlptracehttp.New(ctx, + otlptracehttp.WithEndpoint(""), + otlptracehttp.WithHeaders(map[string]string{ + "x-oneuptime-token": "", + }), + ) + if err != nil { + return nil, err + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName("my-service"), + )), + ) + otel.SetTracerProvider(tp) + return tp, nil +}`, + language: "go", + }; + case "java": + return { + code: `# Run your Java application with the OpenTelemetry agent: +java -javaagent:opentelemetry-javaagent.jar \\ + -Dotel.service.name=my-service \\ + -Dotel.exporter.otlp.endpoint= \\ + -Dotel.exporter.otlp.headers="x-oneuptime-token=" \\ + -Dotel.exporter.otlp.protocol=http/protobuf \\ + -Dotel.metrics.exporter=otlp \\ + -Dotel.logs.exporter=otlp \\ + -jar my-app.jar`, + language: "bash", + }; + case "dotnet": + return { + code: `// Program.cs +using OpenTelemetry; +using OpenTelemetry.Trace; +using OpenTelemetry.Metrics; +using OpenTelemetry.Logs; +using OpenTelemetry.Resources; +using OpenTelemetry.Exporter; + +var builder = WebApplication.CreateBuilder(args); + +var resourceBuilder = ResourceBuilder.CreateDefault() + .AddService("my-service"); + +// Traces +builder.Services.AddOpenTelemetry() + .WithTracing(tracing => tracing + .SetResourceBuilder(resourceBuilder) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter(options => { + options.Endpoint = new Uri(""); + options.Headers = "x-oneuptime-token="; + options.Protocol = OtlpExportProtocol.HttpProtobuf; + }) + ) + .WithMetrics(metrics => metrics + .SetResourceBuilder(resourceBuilder) + .AddAspNetCoreInstrumentation() + .AddOtlpExporter(options => { + options.Endpoint = new Uri(""); + options.Headers = "x-oneuptime-token="; + options.Protocol = OtlpExportProtocol.HttpProtobuf; + }) + ); + +// Logs +builder.Logging.AddOpenTelemetry(logging => { + logging.SetResourceBuilder(resourceBuilder); + logging.AddOtlpExporter(options => { + options.Endpoint = new Uri(""); + options.Headers = "x-oneuptime-token="; + options.Protocol = OtlpExportProtocol.HttpProtobuf; + }); +}); + +var app = builder.Build(); +app.Run();`, + language: "csharp", + }; + case "rust": + return { + code: `use opentelemetry::global; +use opentelemetry_otlp::WithExportConfig; +use opentelemetry_sdk::{trace as sdktrace, Resource}; +use opentelemetry::KeyValue; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use std::collections::HashMap; + +fn init_tracer() -> sdktrace::TracerProvider { + let mut headers = HashMap::new(); + headers.insert( + "x-oneuptime-token".to_string(), + "".to_string(), + ); + + let exporter = opentelemetry_otlp::new_exporter() + .http() + .with_endpoint("") + .with_headers(headers); + + opentelemetry_otlp::new_pipeline() + .tracing() + .with_exporter(exporter) + .with_trace_config( + sdktrace::Config::default() + .with_resource(Resource::new(vec![ + KeyValue::new("service.name", "my-service"), + ])), + ) + .install_batch(opentelemetry_sdk::runtime::Tokio) + .unwrap() +}`, + language: "rust", + }; + case "php": + return { + code: `create( + '/v1/traces', + 'application/x-protobuf', + ['x-oneuptime-token' => ''] +); + +$exporter = new SpanExporter($transport); + +$resource = ResourceInfo::create(Attributes::create([ + ResourceAttributes::SERVICE_NAME => 'my-service', +])); + +$tracerProvider = (new TracerProviderBuilder()) + ->addSpanProcessor(new BatchSpanProcessor($exporter)) + ->setResource($resource) + ->build(); + +Globals::registerTracerProvider($tracerProvider);`, + language: "php", + }; + case "ruby": + return { + code: `# config/initializers/opentelemetry.rb +require 'opentelemetry/sdk' +require 'opentelemetry/exporter/otlp' +require 'opentelemetry/instrumentation/all' + +OpenTelemetry::SDK.configure do |c| + c.service_name = 'my-service' + + c.add_span_processor( + OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new( + OpenTelemetry::Exporter::OTLP::Exporter.new( + endpoint: '/v1/traces', + headers: { 'x-oneuptime-token' => '' } + ) + ) + ) + + c.use_all # Auto-instrument all available libraries +end`, + language: "ruby", + }; + case "elixir": + return { + code: `# config/runtime.exs +config :opentelemetry, + resource: %{service: %{name: "my-service"}}, + span_processor: :batch, + traces_exporter: :otlp + +config :opentelemetry_exporter, + otlp_protocol: :http_protobuf, + otlp_endpoint: "", + otlp_headers: [{"x-oneuptime-token", ""}] + +# In application.ex, add to children: +# {OpentelemetryPhoenix, []}, +# {OpentelemetryEcto, repo: MyApp.Repo}`, + language: "elixir", + }; + case "cpp": + return { + code: `#include +#include +#include +#include +#include + +namespace trace = opentelemetry::trace; +namespace sdktrace = opentelemetry::sdk::trace; +namespace otlp = opentelemetry::exporter::otlp; + +void initTracer() { + otlp::OtlpHttpExporterOptions opts; + opts.url = "/v1/traces"; + opts.http_headers = {{"x-oneuptime-token", ""}}; + + auto exporter = otlp::OtlpHttpExporterFactory::Create(opts); + + sdktrace::BatchSpanProcessorOptions bspOpts; + auto processor = sdktrace::BatchSpanProcessorFactory::Create( + std::move(exporter), bspOpts); + + auto resource = opentelemetry::sdk::resource::Resource::Create({ + {"service.name", "my-service"} + }); + + auto provider = sdktrace::TracerProviderFactory::Create( + std::move(processor), resource); + + trace::Provider::SetTracerProvider(std::move(provider)); +}`, + language: "cpp", + }; + case "swift": + return { + code: `import OpenTelemetryApi +import OpenTelemetrySdk +import OtlpHttpSpanExporting + +func initTracer() { + let exporter = OtlpHttpSpanExporter( + endpoint: URL(string: "/v1/traces")!, + config: OtlpConfiguration( + headers: [("x-oneuptime-token", "")] + ) + ) + + let spanProcessor = BatchSpanProcessor(spanExporter: exporter) + + let tracerProvider = TracerProviderBuilder() + .add(spanProcessor: spanProcessor) + .with(resource: Resource(attributes: [ + ResourceAttributes.serviceName.rawValue: AttributeValue.string("my-service") + ])) + .build() + + OpenTelemetry.registerTracerProvider(tracerProvider: tracerProvider) +}`, + language: "swift", + }; + case "react": + return { + code: `// src/tracing.ts - Import this file in your index.tsx before ReactDOM.render() +import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { ZoneContextManager } from '@opentelemetry/context-zone'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load'; +import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch'; +import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request'; +import { Resource } from '@opentelemetry/resources'; + +const provider = new WebTracerProvider({ + resource: new Resource({ + 'service.name': 'my-react-app', + }), +}); + +provider.addSpanProcessor( + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: '/v1/traces', + headers: { 'x-oneuptime-token': '' }, + }) + ) +); + +provider.register({ + contextManager: new ZoneContextManager(), +}); + +registerInstrumentations({ + instrumentations: [ + new DocumentLoadInstrumentation(), + new FetchInstrumentation({ + propagateTraceHeaderCorsUrls: [/.*/], + }), + new XMLHttpRequestInstrumentation({ + propagateTraceHeaderCorsUrls: [/.*/], + }), + ], +}); + +// In index.tsx: +// import './tracing'; // Must be first import +// import React from 'react'; +// ...`, + language: "typescript", + }; + case "angular": + return { + code: `// src/tracing.ts +import { WebTracerProvider } from '@opentelemetry/sdk-trace-web'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { ZoneContextManager } from '@opentelemetry/context-zone'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load'; +import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch'; +import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request'; +import { Resource } from '@opentelemetry/resources'; + +const provider = new WebTracerProvider({ + resource: new Resource({ + 'service.name': 'my-angular-app', + }), +}); + +provider.addSpanProcessor( + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: '/v1/traces', + headers: { 'x-oneuptime-token': '' }, + }) + ) +); + +provider.register({ + contextManager: new ZoneContextManager(), +}); + +registerInstrumentations({ + instrumentations: [ + new DocumentLoadInstrumentation(), + new FetchInstrumentation({ + propagateTraceHeaderCorsUrls: [/.*/], + }), + new XMLHttpRequestInstrumentation({ + propagateTraceHeaderCorsUrls: [/.*/], + }), + ], +}); + +// In main.ts, import before bootstrapping: +// import './tracing'; // Must be first import +// import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +// ...`, + language: "typescript", + }; + } +} + +function getEnvVarSnippet(): string { + return `# Alternatively, configure via environment variables (works with any language): +export OTEL_SERVICE_NAME="my-service" +export OTEL_EXPORTER_OTLP_ENDPOINT="" +export OTEL_EXPORTER_OTLP_HEADERS="x-oneuptime-token=" +export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"`; +} + +// --- FluentBit snippets --- + +function getFluentBitSnippet(): string { + return `# fluent-bit.conf +[SERVICE] + Flush 5 + Log_Level info + Parsers_File parsers.conf + +[INPUT] + Name tail + Path /var/log/*.log + Tag app.logs + +[OUTPUT] + Name opentelemetry + Match * + Host + Port 443 + Tls On + Header x-oneuptime-token + Logs_uri /v1/logs`; +} + +function getFluentBitDockerSnippet(): string { + return `# docker-compose.yml +services: + fluent-bit: + image: fluent/fluent-bit:latest + volumes: + - ./fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf + - /var/log:/var/log:ro + environment: + - FLB_ES_HOST=`; +} + +// --- Fluentd snippets --- + +function getFluentdSnippet(): string { + return `# fluentd.conf + + @type forward + port 24224 + bind 0.0.0.0 + + + + @type tail + path /var/log/*.log + pos_file /var/log/fluentd.pos + tag app.logs + + @type json + + + + + @type http + endpoint https:///v1/logs + headers {"x-oneuptime-token":""} + json_array true + + @type json + + + @type memory + flush_interval 5s + +`; +} + +function getFluentdDockerSnippet(): string { + return `# docker-compose.yml +services: + fluentd: + image: fluent/fluentd:latest + volumes: + - ./fluentd.conf:/fluentd/etc/fluentd.conf + - /var/log:/var/log:ro + ports: + - "24224:24224"`; +} + +// --- Main Component --- + +const TelemetryDocumentation: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + const [selectedLanguage, setSelectedLanguage] = useState("node"); + const [selectedMethod, setSelectedMethod] = + useState("opentelemetry"); + + // Token management state + const [ingestionKeys, setIngestionKeys] = useState< + Array + >([]); + const [selectedKeyId, setSelectedKeyId] = useState(""); + const [isLoadingKeys, setIsLoadingKeys] = useState(true); + const [showCreateModal, setShowCreateModal] = useState(false); + const [keyError, setKeyError] = useState(""); + + const telemetryType: TelemetryType = props.telemetryType || "logs"; + + const showLogCollectors: boolean = telemetryType === "logs"; + + // Compute OTLP URL and host + const httpProtocol: string = + HTTP_PROTOCOL === Protocol.HTTPS ? "https" : "http"; + const otlpHost: string = HOST ? HOST : ""; + const otlpUrl: string = HOST + ? `${httpProtocol}://${HOST}/otlp` + : ""; + + // Fetch ingestion keys on mount + useEffect(() => { + loadIngestionKeys().catch(() => {}); + }, []); + + const loadIngestionKeys: () => Promise = async (): Promise => { + try { + setIsLoadingKeys(true); + setKeyError(""); + const result: ListResult = + await ModelAPI.getList({ + modelType: TelemetryIngestionKey, + query: { + projectId: ProjectUtil.getCurrentProjectId()!, + }, + limit: 50, + skip: 0, + select: { + _id: true, + name: true, + secretKey: true, + description: true, + }, + sort: {}, + }); + + setIngestionKeys(result.data); + + // Auto-select the first key if available and none selected + if (result.data.length > 0 && !selectedKeyId) { + setSelectedKeyId(result.data[0]!.id?.toString() || ""); + } + } catch (err) { + setKeyError(API.getFriendlyErrorMessage(err as Error)); + } finally { + setIsLoadingKeys(false); + } + }; + + // Get the selected key object + const selectedKey: TelemetryIngestionKey | undefined = useMemo(() => { + return ingestionKeys.find((k: TelemetryIngestionKey) => { + return k.id?.toString() === selectedKeyId; + }); + }, [ingestionKeys, selectedKeyId]); + + // Get token string for code snippets + const tokenValue: string = + selectedKey?.secretKey?.toString() || ""; + const otlpUrlValue: string = otlpUrl; + const otlpHostValue: string = otlpHost; + + const integrationMethods: Array = useMemo(() => { + const methods: Array = [ + { + key: "opentelemetry", + label: "OpenTelemetry", + description: "Recommended. Auto-instrumentation for most frameworks.", + }, + ]; + + if (showLogCollectors) { + methods.push({ + key: "fluentbit", + label: "FluentBit", + description: "Lightweight log processor with minimal resource usage.", + }); + methods.push({ + key: "fluentd", + label: "Fluentd", + description: "Mature data collector with rich plugin ecosystem.", + }); + } + + return methods; + }, [showLogCollectors]); + + const titleForType: Record = { + logs: "Log Ingestion Setup", + metrics: "Metrics Ingestion Setup", + traces: "Trace Ingestion Setup", + exceptions: "Exception Tracking Setup", + }; + + const descriptionForType: Record = { + logs: "Send logs from your application to OneUptime using OpenTelemetry, FluentBit, or Fluentd.", + metrics: + "Send metrics from your application to OneUptime using OpenTelemetry SDKs.", + traces: + "Send distributed traces from your application to OneUptime using OpenTelemetry SDKs.", + exceptions: + "Capture and track exceptions from your application using OpenTelemetry SDKs.", + }; + + const installSnippet: { code: string; language: string } = useMemo(() => { + return getOtelInstallSnippet(selectedLanguage); + }, [selectedLanguage]); + + const configSnippet: { code: string; language: string } = useMemo(() => { + return getOtelConfigSnippet(selectedLanguage); + }, [selectedLanguage]); + + const handleLanguageSelect: (lang: Language) => void = useCallback( + (lang: Language) => { + setSelectedLanguage(lang); + }, + [], ); - const fluentdDocUrl: Route = Route.fromString("/docs/telemetry/fluentd"); + // Step component with vertical line connector + const renderStep: ( + stepNumber: number, + title: string, + description: string, + content: ReactElement, + isLast?: boolean, + ) => ReactElement = ( + stepNumber: number, + title: string, + description: string, + content: ReactElement, + isLast?: boolean, + ): ReactElement => { + return ( +
+ {/* Step indicator with connecting line */} +
+
+ {stepNumber} +
+ {!isLast &&
} +
+ {/* Step content */} +
+

+ {title} +

+

+ {description} +

+ {content} +
+
+ ); + }; - const fluentBitDocUrl: Route = Route.fromString("/docs/telemetry/fluentbit"); + // Token step content (rendered inside Step 1) + const renderTokenStepContent: () => ReactElement = (): ReactElement => { + if (isLoadingKeys) { + return ( +
+

Loading ingestion keys...

+
+ ); + } - const syslogDocUrl: Route = Route.fromString("/docs/telemetry/syslog"); + if (keyError) { + return ( +
+

+ Failed to load ingestion keys: {keyError} +

+ +
+ ); + } + + if (ingestionKeys.length === 0) { + return ( +
+
+ +
+

+ No ingestion keys yet +

+

+ Create an ingestion key to authenticate your telemetry data. +

+ +
+ ); + } + + return ( +
+ {/* Key selector row */} +
+
+ { + return { + value: key.id?.toString() || "", + label: key.name || "Unnamed Key", + }; + }, + )} + value={ + ingestionKeys + .filter((key: TelemetryIngestionKey) => { + return key.id?.toString() === selectedKeyId; + }) + .map((key: TelemetryIngestionKey): DropdownOption => { + return { + value: key.id?.toString() || "", + label: key.name || "Unnamed Key", + }; + })[0] + } + onChange={( + value: DropdownValue | Array | null, + ) => { + if (value) { + setSelectedKeyId(value.toString()); + } + }} + placeholder="Select an ingestion key" + ariaLabel="Select ingestion key" + /> +
+ +
+ + {/* Credentials display */} + {selectedKey && ( +
+
+
+
+ +
+
+
+ OTLP Endpoint +
+
+ {otlpUrlValue} +
+
+
+
+
+ +
+
+
+ Ingestion Token +
+
+ {selectedKey.secretKey?.toString() || "—"} +
+
+
+
+
+ )} +
+ ); + }; + + // Language selector + const renderLanguageSelector: () => ReactElement = (): ReactElement => { + return ( +
+ +
+ {languages.map((lang: LanguageOption) => { + const isSelected: boolean = selectedLanguage === lang.key; + return ( + + ); + })} +
+
+ ); + }; + + // Integration method selector + const renderMethodSelector: () => ReactElement = (): ReactElement => { + if (integrationMethods.length <= 1) { + return <>; + } + + return ( +
+ +
+ {integrationMethods.map((method: IntegrationOption) => { + const isSelected: boolean = selectedMethod === method.key; + return ( + + ); + })} +
+
+ ); + }; + + // OpenTelemetry content + const renderOpenTelemetryContent: () => ReactElement = (): ReactElement => { + return ( +
+ {renderLanguageSelector()} + +
+ {renderStep( + 1, + "Get Your Ingestion Credentials", + "Select an existing ingestion key or create a new one. These credentials authenticate your telemetry data.", + renderTokenStepContent(), + )} + + {renderStep( + 2, + "Install Dependencies", + `Install the OpenTelemetry SDK and exporters for ${ + languages.find((l: LanguageOption) => { + return l.key === selectedLanguage; + })?.label || selectedLanguage + }.`, + , + )} + + {renderStep( + 3, + "Configure the SDK", + "Initialize OpenTelemetry with the OTLP exporter pointing to your OneUptime instance.", + , + )} + + {renderStep( + 4, + "Set Environment Variables (Alternative)", + "You can also configure OpenTelemetry via environment variables instead of code.", + , + true, + )} +
+
+ ); + }; + + // FluentBit content + const renderFluentBitContent: () => ReactElement = (): ReactElement => { + return ( +
+
+ {renderStep( + 1, + "Get Your Ingestion Credentials", + "Select an existing ingestion key or create a new one. These credentials authenticate your telemetry data.", + renderTokenStepContent(), + )} + + {renderStep( + 2, + "Create FluentBit Configuration", + "Create a fluent-bit.conf file that reads logs and forwards them to OneUptime via the OpenTelemetry output plugin.", + , + )} + + {renderStep( + 3, + "Run with Docker (Optional)", + "Run FluentBit as a Docker container alongside your application.", + , + )} + + {renderStep( + 4, + "Run FluentBit", + "Start FluentBit with your configuration file.", + , + true, + )} +
+
+ ); + }; + + // Fluentd content + const renderFluentdContent: () => ReactElement = (): ReactElement => { + return ( +
+
+ {renderStep( + 1, + "Get Your Ingestion Credentials", + "Select an existing ingestion key or create a new one. These credentials authenticate your telemetry data.", + renderTokenStepContent(), + )} + + {renderStep( + 2, + "Create Fluentd Configuration", + "Create a fluentd.conf file that collects logs and sends them to OneUptime over HTTP.", + , + )} + + {renderStep( + 3, + "Run with Docker (Optional)", + "Run Fluentd as a Docker container.", + , + )} + + {renderStep( + 4, + "Run Fluentd", + "Start Fluentd with your configuration.", + , + true, + )} +
+
+ ); + }; + + const renderActiveContent: () => ReactElement = (): ReactElement => { + switch (selectedMethod) { + case "fluentbit": + return renderFluentBitContent(); + case "fluentd": + return renderFluentdContent(); + default: + return renderOpenTelemetryContent(); + } + }; return ( - - +
+
+ {/* Header */} +
+
+
+ + + +
+
+

+ {titleForType[telemetryType]} +

+

+ {descriptionForType[telemetryType]} +

+
+ {props.onClose && ( + + )} +
+
- + {/* Body */} +
+ {renderMethodSelector()} + {renderActiveContent()} +
+
- - - - + {/* Create Ingestion Key Modal */} + {showCreateModal && ( + + modelType={TelemetryIngestionKey} + name="Create Ingestion Key" + title="Create Ingestion Key" + description="Create a new telemetry ingestion key for sending data to OneUptime." + onClose={() => { + setShowCreateModal(false); + }} + submitButtonText="Create Key" + onSuccess={(item: TelemetryIngestionKey) => { + setShowCreateModal(false); + // Refresh the list and select the new key + loadIngestionKeys() + .then(() => { + if (item.id) { + setSelectedKeyId(item.id.toString()); + } + }) + .catch(() => {}); + }} + formProps={{ + name: "Create Ingestion Key", + modelType: TelemetryIngestionKey, + id: "create-ingestion-key", + fields: [ + { + field: { + name: true, + }, + title: "Name", + fieldType: FormFieldSchemaType.Text, + required: true, + placeholder: "e.g. Production Key", + validation: { + minLength: 2, + }, + }, + { + field: { + description: true, + }, + title: "Description", + fieldType: FormFieldSchemaType.LongText, + required: false, + placeholder: "Optional description for this key", + }, + ], + formType: FormType.Create, + }} + onBeforeCreate={( + item: TelemetryIngestionKey, + ): Promise => { + item.projectId = ProjectUtil.getCurrentProjectId()!; + return Promise.resolve(item); + }} + /> + )} +
); }; diff --git a/App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx b/App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx index c7ce469d1b..528dfa275f 100644 --- a/App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Traces/FlameGraph.tsx @@ -37,7 +37,8 @@ const FlameGraph: FunctionComponent = ( const [hoveredSpanId, setHoveredSpanId] = React.useState(null); const [focusedSpanId, setFocusedSpanId] = React.useState(null); - const containerRef: React.RefObject = React.useRef(null); + const containerRef: React.RefObject = + React.useRef(null); // Build span data for critical path utility const spanDataList: SpanData[] = React.useMemo(() => { @@ -90,7 +91,7 @@ const FlameGraph: FunctionComponent = ( } } - const getServiceInfo = ( + const getServiceInfo: (span: Span) => { color: Color; name: string } = ( span: Span, ): { color: Color; name: string } => { const service: Service | undefined = telemetryServices.find( @@ -104,7 +105,10 @@ const FlameGraph: FunctionComponent = ( }; }; - const buildNode = (span: Span, depth: number): FlameGraphNode => { + const buildNode: (span: Span, depth: number) => FlameGraphNode = ( + span: Span, + depth: number, + ): FlameGraphNode => { const children: Span[] = childrenMap.get(span.spanId!) || []; const selfTime: SpanSelfTime | undefined = selfTimes.get(span.spanId!); const serviceInfo: { color: Color; name: string } = getServiceInfo(span); @@ -123,7 +127,9 @@ const FlameGraph: FunctionComponent = ( startTimeUnixNano: span.startTimeUnixNano!, endTimeUnixNano: span.endTimeUnixNano!, durationUnixNano: span.durationUnixNano!, - selfTimeUnixNano: selfTime ? selfTime.selfTimeUnixNano : span.durationUnixNano!, + selfTimeUnixNano: selfTime + ? selfTime.selfTimeUnixNano + : span.durationUnixNano!, serviceColor: serviceInfo.color, serviceName: serviceInfo.name, }; @@ -155,7 +161,9 @@ const FlameGraph: FunctionComponent = ( // Find max depth for height calculation const maxDepth: number = React.useMemo(() => { let max: number = 0; - const traverse = (node: FlameGraphNode): void => { + const traverse: (node: FlameGraphNode) => void = ( + node: FlameGraphNode, + ): void => { if (node.depth > max) { max = node.depth; } @@ -175,7 +183,9 @@ const FlameGraph: FunctionComponent = ( return { viewStart: traceStart, viewEnd: traceEnd }; } - const findNode = (nodes: FlameGraphNode[]): FlameGraphNode | null => { + const findNode: (nodes: FlameGraphNode[]) => FlameGraphNode | null = ( + nodes: FlameGraphNode[], + ): FlameGraphNode | null => { for (const node of nodes) { if (node.span.spanId === focusedSpanId) { return node; @@ -210,7 +220,9 @@ const FlameGraph: FunctionComponent = ( ); } - const renderNode = (node: FlameGraphNode): ReactElement | null => { + const renderNode: (node: FlameGraphNode) => ReactElement | null = ( + node: FlameGraphNode, + ): ReactElement | null => { // Calculate position relative to view const nodeStart: number = Math.max(node.startTimeUnixNano, viewStart); const nodeEnd: number = Math.min(node.endTimeUnixNano, viewEnd); @@ -220,13 +232,9 @@ const FlameGraph: FunctionComponent = ( } const leftPercent: number = - totalDuration > 0 - ? ((nodeStart - viewStart) / totalDuration) * 100 - : 0; + totalDuration > 0 ? ((nodeStart - viewStart) / totalDuration) * 100 : 0; const widthPercent: number = - totalDuration > 0 - ? ((nodeEnd - nodeStart) / totalDuration) * 100 - : 0; + totalDuration > 0 ? ((nodeEnd - nodeStart) / totalDuration) * 100 : 0; const isHovered: boolean = hoveredSpanId === node.span.spanId; const isSelected: boolean = selectedSpanId === node.span.spanId; @@ -302,7 +310,9 @@ const FlameGraph: FunctionComponent = ( if (!hoveredSpanId) { return null; } - const findNode = (nodes: FlameGraphNode[]): FlameGraphNode | null => { + const findNode: (nodes: FlameGraphNode[]) => FlameGraphNode | null = ( + nodes: FlameGraphNode[], + ): FlameGraphNode | null => { for (const node of nodes) { if (node.span.spanId === hoveredSpanId) { return node; diff --git a/App/FeatureSet/Dashboard/src/Components/Traces/TraceExplorer.tsx b/App/FeatureSet/Dashboard/src/Components/Traces/TraceExplorer.tsx index ba96baa5ef..6fa69af3b3 100644 --- a/App/FeatureSet/Dashboard/src/Components/Traces/TraceExplorer.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Traces/TraceExplorer.tsx @@ -712,7 +712,13 @@ const TraceExplorer: FunctionComponent = ( }); } return filtered; - }, [spans, showErrorsOnly, selectedServiceIds, spanSearchText, telemetryServices]); + }, [ + spans, + showErrorsOnly, + selectedServiceIds, + spanSearchText, + telemetryServices, + ]); // Search match count for display const searchMatchCount: number = React.useMemo(() => { @@ -1288,11 +1294,9 @@ const TraceExplorer: FunctionComponent = (
{serviceBreakdown.map((breakdown: ServiceBreakdown) => { - const service: Service | undefined = - telemetryServices.find((s: Service) => { - return ( - s._id?.toString() === breakdown.serviceId - ); - }); - const serviceName: string = - service?.name || "Unknown"; + const service: Service | undefined = telemetryServices.find( + (s: Service) => { + return s._id?.toString() === breakdown.serviceId; + }, + ); + const serviceName: string = service?.name || "Unknown"; const serviceColor: string = String( - (service?.serviceColor as unknown as string) || - "#6366f1", + (service?.serviceColor as unknown as string) || "#6366f1", ); const percent: number = Math.min( breakdown.percentOfTrace, @@ -1446,7 +1447,10 @@ const TraceExplorer: FunctionComponent = ( ); return ( -
+
= (
{SpanUtil.getSpanDurationAsString({ - spanDurationInUnixNano: - breakdown.selfTimeUnixNano, + spanDurationInUnixNano: breakdown.selfTimeUnixNano, divisibilityFactor: divisibilityFactor, })}{" "} ({percent.toFixed(1)}%) @@ -1526,9 +1529,7 @@ const TraceExplorer: FunctionComponent = ( setSelectedSpans([spanId]); }} selectedSpanId={ - selectedSpans.length > 0 - ? selectedSpans[0] - : undefined + selectedSpans.length > 0 ? selectedSpans[0] : undefined } />
diff --git a/App/FeatureSet/Dashboard/src/Components/Traces/TraceServiceMap.tsx b/App/FeatureSet/Dashboard/src/Components/Traces/TraceServiceMap.tsx index 427be6ef4f..2037c5c120 100644 --- a/App/FeatureSet/Dashboard/src/Components/Traces/TraceServiceMap.tsx +++ b/App/FeatureSet/Dashboard/src/Components/Traces/TraceServiceMap.tsx @@ -125,7 +125,8 @@ const TraceServiceMap: FunctionComponent = ( return maxEnd - minStart; }, [spans]); - const divisibilityFactor = SpanUtil.getDivisibilityFactor(traceDuration); + const divisibilityFactor: number = + SpanUtil.getDivisibilityFactor(traceDuration); if (nodes.length === 0) { return ( @@ -135,8 +136,10 @@ const TraceServiceMap: FunctionComponent = ( ); } - // Layout: arrange nodes in a topological order based on edges - // Simple layout: find entry nodes and lay out left-to-right + /* + * Layout: arrange nodes in a topological order based on edges + * Simple layout: find entry nodes and lay out left-to-right + */ const { nodePositions, layoutWidth, layoutHeight } = React.useMemo(() => { // Build adjacency list const adjList: Map = new Map(); @@ -151,10 +154,7 @@ const TraceServiceMap: FunctionComponent = ( const neighbors: string[] = adjList.get(edge.fromServiceId) || []; neighbors.push(edge.toServiceId); adjList.set(edge.fromServiceId, neighbors); - inDegree.set( - edge.toServiceId, - (inDegree.get(edge.toServiceId) || 0) + 1, - ); + inDegree.set(edge.toServiceId, (inDegree.get(edge.toServiceId) || 0) + 1); } // Topological sort using BFS (Kahn's algorithm) @@ -333,18 +333,16 @@ const TraceServiceMap: FunctionComponent = ( refY="3" orient="auto" > - + {/* Render nodes */} {nodes.map((node: ServiceNode) => { - const pos: { x: number; y: number } | undefined = - nodePositions.get(node.serviceId); + const pos: { x: number; y: number } | undefined = nodePositions.get( + node.serviceId, + ); if (!pos) { return null; } @@ -355,9 +353,7 @@ const TraceServiceMap: FunctionComponent = (
| undefined; isMinimalTable?: boolean | undefined; noItemsMessage?: string | undefined; + onFetchSuccess?: + | ((data: Array, totalCount: number) => void) + | undefined; } const TraceTable: FunctionComponent = ( @@ -298,6 +301,7 @@ const TraceTable: FunctionComponent = ( noItemsMessage={ props.noItemsMessage ? props.noItemsMessage : "No spans found." } + onFetchSuccess={props.onFetchSuccess} showRefreshButton={true} sortBy="startTime" sortOrder={SortOrder.Descending} @@ -310,12 +314,9 @@ const TraceTable: FunctionComponent = ( ); } return Promise.resolve( - RouteUtil.populateRouteParams( - RouteMap[PageMap.TRACE_VIEW]!, - { - modelId: span.traceId!.toString(), - }, - ), + RouteUtil.populateRouteParams(RouteMap[PageMap.TRACE_VIEW]!, { + modelId: span.traceId!.toString(), + }), ); }} filters={[ diff --git a/App/FeatureSet/Dashboard/src/Pages/Exceptions/Documentation.tsx b/App/FeatureSet/Dashboard/src/Pages/Exceptions/Documentation.tsx new file mode 100644 index 0000000000..20e80bbae4 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Exceptions/Documentation.tsx @@ -0,0 +1,11 @@ +import PageComponentProps from "../PageComponentProps"; +import React, { FunctionComponent, ReactElement } from "react"; +import TelemetryDocumentation from "../../Components/Telemetry/Documentation"; + +const ExceptionsDocumentationPage: FunctionComponent = ( + _props: PageComponentProps, +): ReactElement => { + return ; +}; + +export default ExceptionsDocumentationPage; diff --git a/App/FeatureSet/Dashboard/src/Pages/Exceptions/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Exceptions/SideMenu.tsx index 85964e7730..8ff658d563 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Exceptions/SideMenu.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Exceptions/SideMenu.tsx @@ -51,6 +51,20 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => { }, ], }, + { + title: "Documentation", + items: [ + { + link: { + title: "Documentation", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.EXCEPTIONS_DOCUMENTATION] as Route, + ), + }, + icon: IconProp.Book, + }, + ], + }, ]; return ; diff --git a/App/FeatureSet/Dashboard/src/Pages/Exceptions/Unresolved.tsx b/App/FeatureSet/Dashboard/src/Pages/Exceptions/Unresolved.tsx index a97e695fdc..227c384a54 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Exceptions/Unresolved.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Exceptions/Unresolved.tsx @@ -1,7 +1,18 @@ import PageComponentProps from "../PageComponentProps"; import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; -import React, { FunctionComponent, ReactElement } from "react"; +import TelemetryDocumentation from "../../Components/Telemetry/Documentation"; +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; import ExceptionsTable from "../../Components/Exceptions/ExceptionsTable"; +import Service from "Common/Models/DatabaseModels/Service"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; const UnresolvedExceptionsPage: FunctionComponent = ( props: PageComponentProps, @@ -9,12 +20,48 @@ const UnresolvedExceptionsPage: FunctionComponent = ( const disableTelemetryForThisProject: boolean = props.currentProject?.reseller?.enableTelemetryFeatures === false; + const [serviceCount, setServiceCount] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchServiceCount: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const count: number = await ModelAPI.count({ + modelType: Service, + query: {}, + }); + setServiceCount(count); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchServiceCount().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + if (disableTelemetryForThisProject) { return ( ); } + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (serviceCount === 0) { + return ; + } + return ( = (): ReactElement => { + const [clusterCount, setClusterCount] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchClusterCount: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const count: number = await ModelAPI.count({ + modelType: KubernetesCluster, + query: {}, + }); + setClusterCount(count); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchClusterCount().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (clusterCount === 0) { + return ( + + + + ); + } + + return ( + + + modelType={KubernetesCluster} + id="kubernetes-clusters-table" + userPreferencesKey="kubernetes-clusters-table" + isDeleteable={false} + isEditable={false} + isCreateable={true} + name="Kubernetes Clusters" + isViewable={true} + filters={[]} + cardProps={{ + title: "Kubernetes Clusters", + description: + "Clusters being monitored in this project. Install the OneUptime kubernetes-agent Helm chart to connect a cluster.", + }} + showViewIdButton={true} + formFields={[ + { + field: { + name: true, + }, + title: "Name", + fieldType: FormFieldSchemaType.Text, + required: true, + placeholder: "production-us-east", + }, + { + field: { + clusterIdentifier: true, + }, + title: "Cluster Identifier", + fieldType: FormFieldSchemaType.Text, + required: true, + placeholder: "production-us-east-1", + description: + "This should match the clusterName value in your kubernetes-agent Helm chart.", + }, + { + field: { + description: true, + }, + title: "Description", + fieldType: FormFieldSchemaType.LongText, + required: false, + placeholder: "Production cluster running in US East", + }, + ]} + columns={[ + { + field: { + name: true, + }, + title: "Name", + type: FieldType.Text, + }, + { + field: { + clusterIdentifier: true, + }, + title: "Cluster Identifier", + type: FieldType.Text, + }, + { + field: { + otelCollectorStatus: true, + }, + title: "Status", + type: FieldType.Text, + }, + { + field: { + nodeCount: true, + }, + title: "Nodes", + type: FieldType.Number, + }, + { + field: { + podCount: true, + }, + title: "Pods", + type: FieldType.Number, + }, + { + field: { + provider: true, + }, + title: "Provider", + type: FieldType.Text, + }, + ]} + onViewPage={(item: KubernetesCluster): Promise => { + return Promise.resolve( + new Route( + RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW] as Route, + { + modelId: item._id, + }, + ).toString(), + ), + ); + }} + /> + + ); +}; + +export default KubernetesClusters; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Documentation.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Documentation.tsx new file mode 100644 index 0000000000..415f8d75d2 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Documentation.tsx @@ -0,0 +1,19 @@ +import PageComponentProps from "../PageComponentProps"; +import React, { Fragment, FunctionComponent, ReactElement } from "react"; +import KubernetesDocumentationCard from "../../Components/Kubernetes/DocumentationCard"; + +const KubernetesDocumentation: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + return ( + + + + ); +}; + +export default KubernetesDocumentation; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Layout.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Layout.tsx new file mode 100644 index 0000000000..d770766081 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Layout.tsx @@ -0,0 +1,25 @@ +import { getKubernetesBreadcrumbs } from "../../Utils/Breadcrumbs"; +import { RouteUtil } from "../../Utils/RouteMap"; +import LayoutPageComponentProps from "../LayoutPageComponentProps"; +import SideMenu from "./SideMenu"; +import Page from "Common/UI/Components/Page/Page"; +import Navigation from "Common/UI/Utils/Navigation"; +import React, { FunctionComponent, ReactElement } from "react"; +import { Outlet } from "react-router-dom"; + +const KubernetesLayout: FunctionComponent = ( + _props: LayoutPageComponentProps, +): ReactElement => { + const path: string = Navigation.getRoutePath(RouteUtil.getRoutes()); + return ( + } + breadcrumbLinks={getKubernetesBreadcrumbs(path)} + > + + + ); +}; + +export default KubernetesLayout; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/SideMenu.tsx new file mode 100644 index 0000000000..829dd70a39 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/SideMenu.tsx @@ -0,0 +1,40 @@ +import PageMap from "../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; +import IconProp from "Common/Types/Icon/IconProp"; +import SideMenu, { + SideMenuSectionProps, +} from "Common/UI/Components/SideMenu/SideMenu"; +import React, { FunctionComponent, ReactElement } from "react"; + +const KubernetesSideMenu: FunctionComponent = (): ReactElement => { + const sections: SideMenuSectionProps[] = [ + { + title: "Kubernetes", + items: [ + { + link: { + title: "All Clusters", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route, + ), + }, + icon: IconProp.List, + }, + { + link: { + title: "Documentation", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_DOCUMENTATION] as Route, + ), + }, + icon: IconProp.Book, + }, + ], + }, + ]; + + return ; +}; + +export default KubernetesSideMenu; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/DocumentationMarkdown.ts b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/DocumentationMarkdown.ts new file mode 100644 index 0000000000..4ca61bf086 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/Utils/DocumentationMarkdown.ts @@ -0,0 +1,144 @@ +export interface KubernetesInstallationMarkdownOptions { + clusterName: string; + oneuptimeUrl: string; + apiKey: string; +} + +export function getKubernetesInstallationMarkdown( + options: KubernetesInstallationMarkdownOptions, +): string { + const { clusterName, oneuptimeUrl, apiKey } = options; + + return ` +## Prerequisites + +- A running Kubernetes cluster (v1.23+) +- \`kubectl\` configured to access your cluster +- \`helm\` v3 installed + +## Step 1: Add the OneUptime Helm Repository + +\`\`\`bash +helm repo add oneuptime https://helm.oneuptime.com +helm repo update +\`\`\` + +## Step 2: Install the Kubernetes Agent + +\`\`\`bash +helm install kubernetes-agent oneuptime/kubernetes-agent \\ + --namespace oneuptime-agent \\ + --create-namespace \\ + --set oneuptime.url="${oneuptimeUrl}" \\ + --set oneuptime.apiKey="${apiKey}" \\ + --set clusterName="${clusterName}" +\`\`\` + +## Step 3: Verify the Installation + +Check that the agent pods are running: + +\`\`\`bash +kubectl get pods -n oneuptime-agent +\`\`\` + +You should see a **Deployment** pod (for metrics and events collection) and **DaemonSet** pods (one per node, for log collection): + +\`\`\` +NAME READY STATUS RESTARTS AGE +kubernetes-agent-deployment-xxxxx-xxxxx 1/1 Running 0 1m +kubernetes-agent-daemonset-xxxxx 1/1 Running 0 1m +\`\`\` + +Once the agent connects, your cluster will appear automatically in the Kubernetes section. + +## Configuration Options + +### Namespace Filtering + +By default, \`kube-system\` is excluded. To monitor only specific namespaces: + +\`\`\`bash +helm install kubernetes-agent oneuptime/kubernetes-agent \\ + --namespace oneuptime-agent \\ + --create-namespace \\ + --set oneuptime.url="${oneuptimeUrl}" \\ + --set oneuptime.apiKey="${apiKey}" \\ + --set clusterName="${clusterName}" \\ + --set "namespaceFilters.include={default,production,staging}" +\`\`\` + +### Disable Log Collection + +If you only need metrics and events (no pod logs): + +\`\`\`bash +helm install kubernetes-agent oneuptime/kubernetes-agent \\ + --namespace oneuptime-agent \\ + --create-namespace \\ + --set oneuptime.url="${oneuptimeUrl}" \\ + --set oneuptime.apiKey="${apiKey}" \\ + --set clusterName="${clusterName}" \\ + --set logs.enabled=false +\`\`\` + +### Enable Control Plane Monitoring + +For self-managed clusters (not EKS/GKE/AKS), you can enable control plane metrics: + +\`\`\`bash +helm install kubernetes-agent oneuptime/kubernetes-agent \\ + --namespace oneuptime-agent \\ + --create-namespace \\ + --set oneuptime.url="${oneuptimeUrl}" \\ + --set oneuptime.apiKey="${apiKey}" \\ + --set clusterName="${clusterName}" \\ + --set controlPlane.enabled=true +\`\`\` + +> **Note:** Managed Kubernetes services (EKS, GKE, AKS) typically do not expose control plane metrics. Only enable this for self-managed clusters. + +## Upgrading the Agent + +\`\`\`bash +helm repo update +helm upgrade kubernetes-agent oneuptime/kubernetes-agent \\ + --namespace oneuptime-agent +\`\`\` + +## Uninstalling the Agent + +\`\`\`bash +helm uninstall kubernetes-agent --namespace oneuptime-agent +kubectl delete namespace oneuptime-agent +\`\`\` + +## What Gets Collected + +The OneUptime Kubernetes Agent collects: + +| Category | Data | +|----------|------| +| **Node Metrics** | CPU utilization, memory usage, filesystem usage, network I/O | +| **Pod Metrics** | CPU usage, memory usage, network I/O, restarts | +| **Container Metrics** | CPU usage, memory usage per container | +| **Cluster Metrics** | Node conditions, allocatable resources, pod counts | +| **Kubernetes Events** | Warnings, errors, scheduling events | +| **Pod Logs** | stdout/stderr logs from all containers (via DaemonSet) | + +## Troubleshooting + +### Agent shows "Disconnected" + +1. Check that the agent pods are running: \`kubectl get pods -n oneuptime-agent\` +2. Check the agent logs: \`kubectl logs -n oneuptime-agent deployment/kubernetes-agent-deployment\` +3. Verify your OneUptime URL and API key are correct +4. Ensure your cluster can reach the OneUptime instance over the network + +### No metrics appearing + +1. Check that the cluster identifier matches: this cluster uses **\`${clusterName}\`** +2. Verify the RBAC permissions: \`kubectl get clusterrolebinding | grep kubernetes-agent\` +3. Check the OTel collector logs for export errors +`; +} diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx new file mode 100644 index 0000000000..5fb856a253 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/ControlPlane.tsx @@ -0,0 +1,314 @@ +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 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, + 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"; + +const KubernetesClusterControlPlane: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [etcdMetricViewData, setEtcdMetricViewData] = + useState(null); + const [apiServerMetricViewData, setApiServerMetricViewData] = + useState(null); + const [schedulerMetricViewData, setSchedulerMetricViewData] = + useState(null); + const [controllerMetricViewData, setControllerMetricViewData] = + useState(null); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + 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)); + }); + }, []); + + useEffect(() => { + if (!cluster) { + return; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const etcdDbSizeQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "etcd_db_size", + title: "etcd Database Size", + description: "Total size of the etcd database", + legend: "DB Size", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "etcd_mvcc_db_total_size_in_bytes", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const apiServerRequestRateQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "apiserver_requests", + title: "API Server Request Rate", + description: "Total API server requests by verb", + legend: "Requests", + legendUnit: "req/s", + }, + metricQueryData: { + filterData: { + metricName: "apiserver_request_total", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Sum, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const apiServerLatencyQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "apiserver_latency", + title: "API Server Request Latency", + description: "API server request duration", + legend: "Latency", + legendUnit: "seconds", + }, + metricQueryData: { + filterData: { + metricName: "apiserver_request_duration_seconds", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const schedulerPendingQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "scheduler_pending", + title: "Scheduler Pending Pods", + description: "Number of pods pending scheduling", + legend: "Pending Pods", + legendUnit: "", + }, + metricQueryData: { + filterData: { + metricName: "scheduler_pending_pods", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const schedulerLatencyQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "scheduler_latency", + title: "Scheduler Latency", + description: "End-to-end scheduling latency", + legend: "Latency", + legendUnit: "seconds", + }, + metricQueryData: { + filterData: { + metricName: "scheduler_e2e_scheduling_duration_seconds", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const controllerQueueDepthQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "controller_queue", + title: "Controller Manager Queue Depth", + description: "Work queue depth for controller manager", + legend: "Queue Depth", + legendUnit: "", + }, + metricQueryData: { + filterData: { + metricName: "workqueue_depth", + attributes: { + "resource.k8s.cluster.name": clusterIdentifier, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + setEtcdMetricViewData({ + startAndEndDate: startAndEndDate, + queryConfigs: [etcdDbSizeQuery], + formulaConfigs: [], + }); + + setApiServerMetricViewData({ + startAndEndDate: startAndEndDate, + queryConfigs: [apiServerRequestRateQuery, apiServerLatencyQuery], + formulaConfigs: [], + }); + + setSchedulerMetricViewData({ + startAndEndDate: startAndEndDate, + queryConfigs: [schedulerPendingQuery, schedulerLatencyQuery], + formulaConfigs: [], + }); + + setControllerMetricViewData({ + startAndEndDate: startAndEndDate, + queryConfigs: [controllerQueueDepthQuery], + formulaConfigs: [], + }); + }, [cluster]); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if ( + !cluster || + !etcdMetricViewData || + !apiServerMetricViewData || + !schedulerMetricViewData || + !controllerMetricViewData + ) { + return ; + } + + return ( + +
+

+ Control plane metrics require the controlPlane.enabled{" "} + flag to be set to true in the kubernetes-agent Helm chart + values. This is typically only available for self-managed Kubernetes + clusters, not managed services like EKS, GKE, or AKS. +

+
+ + + {}} + /> + + + + {}} + /> + + + + {}} + /> + + + + {}} + /> + +
+ ); +}; + +export default KubernetesClusterControlPlane; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Delete.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Delete.tsx new file mode 100644 index 0000000000..6c8e190e8e --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Delete.tsx @@ -0,0 +1,34 @@ +import PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import PageComponentProps from "../../PageComponentProps"; +import Route from "Common/Types/API/Route"; +import ObjectID from "Common/Types/ObjectID"; +import ModelDelete from "Common/UI/Components/ModelDelete/ModelDelete"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import React, { Fragment, FunctionComponent, ReactElement } from "react"; + +const KubernetesClusterDelete: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + return ( + + { + Navigation.navigate( + RouteUtil.populateRouteParams( + RouteMap[PageMap.KUBERNETES_CLUSTERS] as Route, + { modelId }, + ), + ); + }} + /> + + ); +}; + +export default KubernetesClusterDelete; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Documentation.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Documentation.tsx new file mode 100644 index 0000000000..604c8eb7e9 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Documentation.tsx @@ -0,0 +1,78 @@ +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 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 KubernetesDocumentationCard from "../../../Components/Kubernetes/DocumentationCard"; + +const KubernetesClusterDocumentation: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + name: true, + clusterIdentifier: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterName: string = + cluster.clusterIdentifier || cluster.name || "my-cluster"; + + return ( + + + + ); +}; + +export default KubernetesClusterDocumentation; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx new file mode 100644 index 0000000000..ba6e56a86b --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Events.tsx @@ -0,0 +1,339 @@ +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 AnalyticsModelAPI, { + ListResult, +} from "Common/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI"; +import Log from "Common/Models/AnalyticsModels/Log"; +import ProjectUtil from "Common/UI/Utils/Project"; +import OneUptimeDate from "Common/Types/Date"; +import SortOrder from "Common/Types/BaseDatabase/SortOrder"; +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 { 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; +} + +const KubernetesClusterEvents: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [events, setEvents] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchData: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + clusterIdentifier: true, + }, + }); + setCluster(item); + + if (!item?.clusterIdentifier) { + setIsLoading(false); + return; + } + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24); + + const listResult: ListResult = await AnalyticsModelAPI.getList({ + modelType: Log, + query: { + projectId: ProjectUtil.getCurrentProjectId()!.toString(), + time: new InBetween(startDate, endDate), + }, + limit: 200, + skip: 0, + select: { + time: true, + body: true, + severityText: true, + attributes: true, + }, + sort: { + 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 | undefined = (kvList as JSONObject)[ + "values" + ] as Array | 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 | undefined = (kvList as JSONObject)[ + "values" + ] as Array | 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 k8sEvents: Array = []; + + for (const log of listResult.data) { + const attrs: JSONObject = log.attributes || {}; + + // Filter to only k8s events from this cluster + if ( + attrs["resource.k8s.cluster.name"] !== item.clusterIdentifier && + attrs["k8s.cluster.name"] !== item.clusterIdentifier + ) { + 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 { + if (typeof log.body === "string") { + bodyObj = JSON.parse(log.body) as JSONObject; + } + } catch { + continue; + } + + if (!bodyObj) { + continue; + } + + // The body has a top-level kvlistValue with "type" (ADDED/MODIFIED) and "object" keys + const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as + | JSONObject + | undefined; + if (!topKvList) { + continue; + } + + // Get the "object" which is the actual k8s Event + const objectKvListRaw: string = getKvValue(topKvList, "object"); + if (!objectKvListRaw || typeof objectKvListRaw === "string") { + continue; + } + const objectKvList: JSONObject = + objectKvListRaw as unknown as JSONObject; + + const eventType: string = getKvValue(objectKvList, "type") || ""; + const reason: string = getKvValue(objectKvList, "reason") || ""; + const note: string = getKvValue(objectKvList, "note") || ""; + + // 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") || + ""; + + if (eventType || reason) { + k8sEvents.push({ + timestamp: log.time + ? OneUptimeDate.getDateAsLocalFormattedString(log.time) + : "", + type: eventType || "Unknown", + reason: reason || "Unknown", + objectKind: objectKind || "Unknown", + objectName: objectName || "Unknown", + namespace: namespace || "default", + message: note || "", + }); + } + } + + setEvents(k8sEvents); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchData().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + return ( + + + {events.length === 0 ? ( +

+ No Kubernetes events found in the last 24 hours. Events will appear + here once the kubernetes-agent is sending data. +

+ ) : ( +
+ + + + + + + + + + + + + {events.map((event: KubernetesEvent, index: number) => { + const isWarning: boolean = + event.type.toLowerCase() === "warning"; + return ( + + + + + + + + + ); + })} + +
+ Time + + Type + + Reason + + Object + + Namespace + + Message +
+ {event.timestamp} + + + {event.type} + + + {event.reason} + + {event.objectKind}/{event.objectName} + + {event.namespace} + + {event.message} +
+
+ )} +
+
+ ); +}; + +export default KubernetesClusterEvents; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx new file mode 100644 index 0000000000..fb10436918 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Index.tsx @@ -0,0 +1,215 @@ +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 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 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"; + +const KubernetesClusterOverview: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const item: KubernetesCluster | null = await ModelAPI.getItem({ + modelType: KubernetesCluster, + id: modelId, + select: { + name: true, + clusterIdentifier: true, + provider: true, + otelCollectorStatus: true, + lastSeenAt: true, + nodeCount: true, + podCount: true, + namespaceCount: true, + }, + }); + setCluster(item); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchCluster().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const statusColor: string = + cluster.otelCollectorStatus === "connected" + ? "text-green-600" + : "text-red-600"; + + return ( + + {/* Summary Cards */} +
+ + {cluster.nodeCount?.toString() || "0"} + + } + /> + + {cluster.podCount?.toString() || "0"} + + } + /> + + {cluster.namespaceCount?.toString() || "0"} + + } + /> + + {cluster.otelCollectorStatus === "connected" + ? "Connected" + : "Disconnected"} + + } + /> +
+ + {/* Cluster Details */} + + name="Cluster Details" + cardProps={{ + title: "Cluster Details", + description: "Basic information about this Kubernetes cluster.", + }} + isEditable={true} + editButtonText="Edit Cluster" + formFields={[ + { + field: { + name: true, + }, + title: "Name", + fieldType: FormFieldSchemaType.Text, + required: true, + placeholder: "production-us-east", + }, + { + field: { + description: true, + }, + title: "Description", + fieldType: FormFieldSchemaType.LongText, + required: false, + placeholder: "Production cluster running in US East", + }, + { + field: { + clusterIdentifier: true, + }, + title: "Cluster Identifier", + fieldType: FormFieldSchemaType.Text, + required: true, + placeholder: "production-us-east-1", + description: + "This should match the clusterName value in your kubernetes-agent Helm chart.", + }, + { + field: { + provider: true, + }, + title: "Provider", + fieldType: FormFieldSchemaType.Text, + required: false, + placeholder: "EKS, GKE, AKS, etc.", + }, + ]} + modelDetailProps={{ + showDetailsInNumberOfColumns: 2, + modelType: KubernetesCluster, + id: "kubernetes-cluster-overview", + modelId: modelId, + fields: [ + { + field: { + name: true, + }, + title: "Cluster Name", + fieldType: FieldType.Text, + }, + { + field: { + clusterIdentifier: true, + }, + title: "Cluster Identifier", + fieldType: FieldType.Text, + }, + { + field: { + description: true, + }, + title: "Description", + fieldType: FieldType.Text, + }, + { + field: { + provider: true, + }, + title: "Provider", + fieldType: FieldType.Text, + }, + { + field: { + lastSeenAt: true, + }, + title: "Last Seen", + fieldType: FieldType.DateTime, + }, + ], + }} + /> +
+ ); +}; + +export default KubernetesClusterOverview; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Layout.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Layout.tsx new file mode 100644 index 0000000000..cb6ee5e0c6 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Layout.tsx @@ -0,0 +1,32 @@ +import { getKubernetesBreadcrumbs } from "../../../Utils/Breadcrumbs"; +import { RouteUtil } from "../../../Utils/RouteMap"; +import PageComponentProps from "../../PageComponentProps"; +import SideMenu from "./SideMenu"; +import ObjectID from "Common/Types/ObjectID"; +import ModelPage from "Common/UI/Components/Page/ModelPage"; +import Navigation from "Common/UI/Utils/Navigation"; +import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster"; +import React, { FunctionComponent, ReactElement } from "react"; +import { Outlet, useParams } from "react-router-dom"; + +const KubernetesClusterViewLayout: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const { id } = useParams(); + const modelId: ObjectID = new ObjectID(id || ""); + const path: string = Navigation.getRoutePath(RouteUtil.getRoutes()); + return ( + } + > + + + ); +}; + +export default KubernetesClusterViewLayout; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx new file mode 100644 index 0000000000..1618746cc8 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/NodeDetail.tsx @@ -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 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, + 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"; + +const KubernetesClusterNodeDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const nodeName: string = Navigation.getLastParam()?.toString() || ""; + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + 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 ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_cpu", + title: "CPU Utilization", + description: `CPU utilization for node ${nodeName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_memory", + title: "Memory Usage", + description: `Memory usage for node ${nodeName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const filesystemQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_filesystem", + title: "Filesystem Usage", + description: `Filesystem usage for node ${nodeName}`, + legend: "Filesystem", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.filesystem.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const networkRxQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_network_rx", + title: "Network Receive", + description: `Network bytes received for node ${nodeName}`, + legend: "Network RX", + legendUnit: "bytes/s", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.network.io.receive", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const networkTxQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "node_network_tx", + title: "Network Transmit", + description: `Network bytes transmitted for node ${nodeName}`, + legend: "Network TX", + legendUnit: "bytes/s", + }, + metricQueryData: { + filterData: { + metricName: "k8s.node.network.io.transmit", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.node.name": nodeName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const [metricViewData, setMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [ + cpuQuery, + memoryQuery, + filesystemQuery, + networkRxQuery, + networkTxQuery, + ], + formulaConfigs: [], + }); + + return ( + +
+ + +
+ + + { + setMetricViewData({ + ...data, + queryConfigs: [ + cpuQuery, + memoryQuery, + filesystemQuery, + networkRxQuery, + networkTxQuery, + ], + formulaConfigs: [], + }); + }} + /> + +
+ ); +}; + +export default KubernetesClusterNodeDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx new file mode 100644 index 0000000000..ae95b70e2e --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Nodes.tsx @@ -0,0 +1,220 @@ +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 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"; + +const KubernetesClusterNodes: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [metricViewData, setMetricViewData] = useState( + null, + ); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + 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)); + }); + }, []); + + useEffect(() => { + if (!cluster) { + return; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const getNodeSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + 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 ; + } + + if (error) { + return ; + } + + if (!cluster || !metricViewData) { + return ; + } + + return ( + + { + setMetricViewData({ + ...data, + queryConfigs: metricViewData.queryConfigs, + formulaConfigs: [], + }); + }} + /> + + ); +}; + +export default KubernetesClusterNodes; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx new file mode 100644 index 0000000000..21711434f2 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PodDetail.tsx @@ -0,0 +1,226 @@ +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 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, + 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"; + +const KubernetesClusterPodDetail: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(2); + const podName: string = Navigation.getLastParam()?.toString() || ""; + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + 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 ; + } + + if (error) { + return ; + } + + if (!cluster) { + return ; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const getContainerSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + const containerName: string = + (attributes["k8s.container.name"] as string) || "Unknown Container"; + return { title: containerName }; + }; + + const cpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "container_cpu", + title: "Container CPU Utilization", + description: `CPU utilization for containers in pod ${podName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "container.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.pod.name": podName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getContainerSeries, + }; + + const memoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "container_memory", + title: "Container Memory Usage", + description: `Memory usage for containers in pod ${podName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "container.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.pod.name": podName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + getSeries: getContainerSeries, + }; + + const podCpuQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_cpu", + title: "Pod CPU Utilization", + description: `CPU utilization for pod ${podName}`, + legend: "CPU", + legendUnit: "%", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.cpu.utilization", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.pod.name": podName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const podMemoryQuery: MetricQueryConfigData = { + metricAliasData: { + metricVariable: "pod_memory", + title: "Pod Memory Usage", + description: `Memory usage for pod ${podName}`, + legend: "Memory", + legendUnit: "bytes", + }, + metricQueryData: { + filterData: { + metricName: "k8s.pod.memory.usage", + attributes: { + "k8s.cluster.name": clusterIdentifier, + "k8s.pod.name": podName, + }, + aggegationType: AggregationType.Avg, + aggregateBy: {}, + }, + groupBy: { + attributes: true, + }, + }, + }; + + const [metricViewData, setMetricViewData] = useState({ + startAndEndDate: startAndEndDate, + queryConfigs: [podCpuQuery, podMemoryQuery, cpuQuery, memoryQuery], + formulaConfigs: [], + }); + + return ( + +
+ + +
+ + + { + setMetricViewData({ + ...data, + queryConfigs: [ + podCpuQuery, + podMemoryQuery, + cpuQuery, + memoryQuery, + ], + formulaConfigs: [], + }); + }} + /> + +
+ ); +}; + +export default KubernetesClusterPodDetail; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx new file mode 100644 index 0000000000..f8191c2541 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Pods.tsx @@ -0,0 +1,223 @@ +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 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"; + +const KubernetesClusterPods: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + const [cluster, setCluster] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [metricViewData, setMetricViewData] = useState( + null, + ); + + const fetchCluster: PromiseVoidFunction = async (): Promise => { + 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)); + }); + }, []); + + useEffect(() => { + if (!cluster) { + return; + } + + const clusterIdentifier: string = cluster.clusterIdentifier || ""; + const endDate: Date = OneUptimeDate.getCurrentDate(); + const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6); + const startAndEndDate: InBetween = new InBetween(startDate, endDate); + + const getPodSeries: (data: AggregateModel) => ChartSeries = ( + data: AggregateModel, + ): ChartSeries => { + const attributes: Record = + (data["attributes"] as Record) || {}; + 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 ; + } + + if (error) { + return ; + } + + if (!cluster || !metricViewData) { + return ; + } + + return ( + + { + setMetricViewData({ + ...data, + queryConfigs: metricViewData.queryConfigs, + formulaConfigs: [], + }); + }} + /> + + ); +}; + +export default KubernetesClusterPods; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Settings.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Settings.tsx new file mode 100644 index 0000000000..700df5de1a --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Settings.tsx @@ -0,0 +1,64 @@ +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 CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail"; +import FieldType from "Common/UI/Components/Types/FieldType"; +import React, { Fragment, FunctionComponent, ReactElement } from "react"; + +const KubernetesClusterSettings: FunctionComponent< + PageComponentProps +> = (): ReactElement => { + const modelId: ObjectID = Navigation.getLastParamAsObjectID(1); + + return ( + + + name="Cluster Settings" + cardProps={{ + title: "Cluster Settings", + description: "Manage settings for this Kubernetes cluster.", + }} + isEditable={true} + editButtonText="Edit Settings" + modelDetailProps={{ + modelType: KubernetesCluster, + id: "kubernetes-cluster-settings", + modelId: modelId, + fields: [ + { + field: { + name: true, + }, + title: "Name", + fieldType: FieldType.Text, + }, + { + field: { + description: true, + }, + title: "Description", + fieldType: FieldType.Text, + }, + { + field: { + clusterIdentifier: true, + }, + title: "Cluster Identifier", + fieldType: FieldType.Text, + }, + { + field: { + provider: true, + }, + title: "Provider", + fieldType: FieldType.Text, + }, + ], + }} + /> + + ); +}; + +export default KubernetesClusterSettings; diff --git a/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx new file mode 100644 index 0000000000..ff62834736 --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/SideMenu.tsx @@ -0,0 +1,106 @@ +import PageMap from "../../../Utils/PageMap"; +import RouteMap, { RouteUtil } from "../../../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; +import IconProp from "Common/Types/Icon/IconProp"; +import ObjectID from "Common/Types/ObjectID"; +import SideMenu from "Common/UI/Components/SideMenu/SideMenu"; +import SideMenuItem from "Common/UI/Components/SideMenu/SideMenuItem"; +import SideMenuSection from "Common/UI/Components/SideMenu/SideMenuSection"; +import React, { FunctionComponent, ReactElement } from "react"; + +export interface ComponentProps { + modelId: ObjectID; +} + +const KubernetesClusterSideMenu: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + return ( + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default KubernetesClusterSideMenu; diff --git a/App/FeatureSet/Dashboard/src/Pages/Logs/Index.tsx b/App/FeatureSet/Dashboard/src/Pages/Logs/Index.tsx index 3f962f68ef..57a19db08d 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Logs/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Logs/Index.tsx @@ -1,7 +1,18 @@ import PageComponentProps from "../PageComponentProps"; import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; -import React, { FunctionComponent, ReactElement } from "react"; +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; import DashboardLogsViewer from "../../Components/Logs/LogsViewer"; +import TelemetryDocumentation from "../../Components/Telemetry/Documentation"; +import Service from "Common/Models/DatabaseModels/Service"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; const LogsPage: FunctionComponent = ( props: PageComponentProps, @@ -9,12 +20,60 @@ const LogsPage: FunctionComponent = ( const disableTelemetryForThisProject: boolean = props.currentProject?.reseller?.enableTelemetryFeatures === false; + const [serviceCount, setServiceCount] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [showDocs, setShowDocs] = useState(false); + + const fetchServiceCount: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const count: number = await ModelAPI.count({ + modelType: Service, + query: {}, + }); + setServiceCount(count); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchServiceCount().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + if (disableTelemetryForThisProject) { return ( ); } + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (showDocs) { + return ( + { + setShowDocs(false); + }} + /> + ); + } + + if (serviceCount === 0) { + return ; + } + return ( = ( limit={100} enableRealtime={true} id="logs" + onShowDocumentation={() => { + setShowDocs(true); + }} /> ); }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Metrics/Documentation.tsx b/App/FeatureSet/Dashboard/src/Pages/Metrics/Documentation.tsx new file mode 100644 index 0000000000..c3f2bd219f --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Metrics/Documentation.tsx @@ -0,0 +1,11 @@ +import PageComponentProps from "../PageComponentProps"; +import React, { FunctionComponent, ReactElement } from "react"; +import TelemetryDocumentation from "../../Components/Telemetry/Documentation"; + +const MetricsDocumentationPage: FunctionComponent = ( + _props: PageComponentProps, +): ReactElement => { + return ; +}; + +export default MetricsDocumentationPage; diff --git a/App/FeatureSet/Dashboard/src/Pages/Metrics/Index.tsx b/App/FeatureSet/Dashboard/src/Pages/Metrics/Index.tsx index 96eb4d4fde..acfa75e49b 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Metrics/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Metrics/Index.tsx @@ -1,7 +1,18 @@ import PageComponentProps from "../PageComponentProps"; import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; -import React, { FunctionComponent, ReactElement } from "react"; +import TelemetryDocumentation from "../../Components/Telemetry/Documentation"; +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; import MetricsTable from "../../Components/Metrics/MetricsTable"; +import Service from "Common/Models/DatabaseModels/Service"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; const MetricsPage: FunctionComponent = ( props: PageComponentProps, @@ -9,12 +20,48 @@ const MetricsPage: FunctionComponent = ( const disableTelemetryForThisProject: boolean = props.currentProject?.reseller?.enableTelemetryFeatures === false; + const [serviceCount, setServiceCount] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchServiceCount: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const count: number = await ModelAPI.count({ + modelType: Service, + query: {}, + }); + setServiceCount(count); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchServiceCount().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + if (disableTelemetryForThisProject) { return ( ); } + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (serviceCount === 0) { + return ; + } + return ; }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Metrics/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Metrics/SideMenu.tsx index d8546adcb0..1b25606201 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Metrics/SideMenu.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Metrics/SideMenu.tsx @@ -23,6 +23,20 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => { }, ], }, + { + title: "Documentation", + items: [ + { + link: { + title: "Documentation", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.METRICS_DOCUMENTATION] as Route, + ), + }, + icon: IconProp.Book, + }, + ], + }, ]; return ; diff --git a/App/FeatureSet/Dashboard/src/Pages/Settings/MobileApps.tsx b/App/FeatureSet/Dashboard/src/Pages/Settings/MobileApps.tsx new file mode 100644 index 0000000000..3886f3b3bb --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Settings/MobileApps.tsx @@ -0,0 +1,56 @@ +import PageComponentProps from "../PageComponentProps"; +import IconProp from "Common/Types/Icon/IconProp"; +import Card from "Common/UI/Components/Card/Card"; +import React, { Fragment, FunctionComponent, ReactElement } from "react"; + +const MobileApps: FunctionComponent = (): ReactElement => { + return ( + + { + window.open( + "https://apps.apple.com/us/app/oneuptime-on-call/id6759615391", + "_blank", + ); + }, + }, + ]} + /> + + { + window.open( + "https://play.google.com/store/apps/details?id=com.oneuptime.oncall", + "_blank", + ); + }, + }, + { + title: "Download APK", + icon: IconProp.Download, + onClick: () => { + window.open( + "https://github.com/OneUptime/oneuptime/releases/latest/download/oneuptime-on-call-android-app.apk", + "_blank", + ); + }, + }, + ]} + /> + + ); +}; + +export default MobileApps; diff --git a/App/FeatureSet/Dashboard/src/Pages/Settings/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Settings/SideMenu.tsx index 7a02896064..3c7ce9ef31 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Settings/SideMenu.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Settings/SideMenu.tsx @@ -147,6 +147,15 @@ const DashboardSideMenu: () => JSX.Element = (): ReactElement => { }, icon: IconProp.Bell, }, + { + link: { + title: "Mobile Apps", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.SETTINGS_MOBILE_APPS] as Route, + ), + }, + icon: IconProp.DevicePhoneMobile, + }, ], }, { diff --git a/App/FeatureSet/Dashboard/src/Pages/Traces/Documentation.tsx b/App/FeatureSet/Dashboard/src/Pages/Traces/Documentation.tsx new file mode 100644 index 0000000000..0cd77ad0ac --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Pages/Traces/Documentation.tsx @@ -0,0 +1,11 @@ +import PageComponentProps from "../PageComponentProps"; +import React, { FunctionComponent, ReactElement } from "react"; +import TelemetryDocumentation from "../../Components/Telemetry/Documentation"; + +const TracesDocumentationPage: FunctionComponent = ( + _props: PageComponentProps, +): ReactElement => { + return ; +}; + +export default TracesDocumentationPage; diff --git a/App/FeatureSet/Dashboard/src/Pages/Traces/Index.tsx b/App/FeatureSet/Dashboard/src/Pages/Traces/Index.tsx index 77b99f4474..d482e618dc 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Traces/Index.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Traces/Index.tsx @@ -1,7 +1,18 @@ import PageComponentProps from "../PageComponentProps"; import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage"; -import React, { FunctionComponent, ReactElement } from "react"; +import TelemetryDocumentation from "../../Components/Telemetry/Documentation"; +import React, { + FunctionComponent, + ReactElement, + useEffect, + useState, +} from "react"; import TraceTable from "../../Components/Traces/TraceTable"; +import Service from "Common/Models/DatabaseModels/Service"; +import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI"; +import API from "Common/UI/Utils/API/API"; +import PageLoader from "Common/UI/Components/Loader/PageLoader"; +import { PromiseVoidFunction } from "Common/Types/FunctionTypes"; const TracesPage: FunctionComponent = ( props: PageComponentProps, @@ -9,12 +20,48 @@ const TracesPage: FunctionComponent = ( const disableTelemetryForThisProject: boolean = props.currentProject?.reseller?.enableTelemetryFeatures === false; + const [serviceCount, setServiceCount] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + + const fetchServiceCount: PromiseVoidFunction = async (): Promise => { + setIsLoading(true); + try { + const count: number = await ModelAPI.count({ + modelType: Service, + query: {}, + }); + setServiceCount(count); + } catch (err) { + setError(API.getFriendlyMessage(err)); + } + setIsLoading(false); + }; + + useEffect(() => { + fetchServiceCount().catch((err: Error) => { + setError(API.getFriendlyMessage(err)); + }); + }, []); + if (disableTelemetryForThisProject) { return ( ); } + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (serviceCount === 0) { + return ; + } + return ; }; diff --git a/App/FeatureSet/Dashboard/src/Pages/Traces/SideMenu.tsx b/App/FeatureSet/Dashboard/src/Pages/Traces/SideMenu.tsx index 7da1df9c7c..703a567963 100644 --- a/App/FeatureSet/Dashboard/src/Pages/Traces/SideMenu.tsx +++ b/App/FeatureSet/Dashboard/src/Pages/Traces/SideMenu.tsx @@ -23,6 +23,20 @@ const DashboardSideMenu: FunctionComponent = (): ReactElement => { }, ], }, + { + title: "Documentation", + items: [ + { + link: { + title: "Documentation", + to: RouteUtil.populateRouteParams( + RouteMap[PageMap.TRACES_DOCUMENTATION] as Route, + ), + }, + icon: IconProp.Book, + }, + ], + }, ]; return ; diff --git a/App/FeatureSet/Dashboard/src/Routes/AllRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/AllRoutes.tsx index bd7c4fad97..18063248e3 100644 --- a/App/FeatureSet/Dashboard/src/Routes/AllRoutes.tsx +++ b/App/FeatureSet/Dashboard/src/Routes/AllRoutes.tsx @@ -20,6 +20,7 @@ export { default as StatusPagesRoutes } from "./StatusPagesRoutes"; export { default as DashboardRoutes } from "./DashboardRoutes"; export { default as ServiceRoutes } from "./ServiceRoutes"; export { default as CodeRepositoryRoutes } from "./CodeRepositoryRoutes"; +export { default as KubernetesRoutes } from "./KubernetesRoutes"; export { default as AIAgentTasksRoutes } from "./AIAgentTasksRoutes"; // Settings diff --git a/App/FeatureSet/Dashboard/src/Routes/ExceptionsRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/ExceptionsRoutes.tsx index 5d34e7e8ec..2d9f367dcd 100644 --- a/App/FeatureSet/Dashboard/src/Routes/ExceptionsRoutes.tsx +++ b/App/FeatureSet/Dashboard/src/Routes/ExceptionsRoutes.tsx @@ -9,11 +9,9 @@ import { Route as PageRoute, Routes } from "react-router-dom"; // Pages import ExceptionsUnresolved from "../Pages/Exceptions/Unresolved"; - import ExceptionsResolved from "../Pages/Exceptions/Resolved"; - import ExceptionsArchived from "../Pages/Exceptions/Archived"; - +import ExceptionsDocumentationPage from "../Pages/Exceptions/Documentation"; import ExceptionView from "../Pages/Exceptions/View/Index"; const ExceptionsRoutes: FunctionComponent = ( @@ -61,6 +59,15 @@ const ExceptionsRoutes: FunctionComponent = ( /> } /> + + } + /> {/* Exception View - separate from main layout */} diff --git a/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx new file mode 100644 index 0000000000..ae8920621d --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx @@ -0,0 +1,179 @@ +import ComponentProps from "../Pages/PageComponentProps"; +import KubernetesLayout from "../Pages/Kubernetes/Layout"; +import KubernetesClusterViewLayout from "../Pages/Kubernetes/View/Layout"; +import PageMap from "../Utils/PageMap"; +import RouteMap, { RouteUtil, KubernetesRoutePath } from "../Utils/RouteMap"; +import Route from "Common/Types/API/Route"; +import React, { FunctionComponent, ReactElement } from "react"; +import { Route as PageRoute, Routes } from "react-router-dom"; + +// Pages +import KubernetesClusters from "../Pages/Kubernetes/Clusters"; +import KubernetesClusterView from "../Pages/Kubernetes/View/Index"; +import KubernetesClusterViewPods from "../Pages/Kubernetes/View/Pods"; +import KubernetesClusterViewPodDetail from "../Pages/Kubernetes/View/PodDetail"; +import KubernetesClusterViewNodes from "../Pages/Kubernetes/View/Nodes"; +import KubernetesClusterViewNodeDetail from "../Pages/Kubernetes/View/NodeDetail"; +import KubernetesClusterViewEvents from "../Pages/Kubernetes/View/Events"; +import KubernetesClusterViewControlPlane from "../Pages/Kubernetes/View/ControlPlane"; +import KubernetesClusterViewDelete from "../Pages/Kubernetes/View/Delete"; +import KubernetesClusterViewDocumentation from "../Pages/Kubernetes/View/Documentation"; +import KubernetesDocumentation from "../Pages/Kubernetes/Documentation"; + +const KubernetesRoutes: FunctionComponent = ( + props: ComponentProps, +): ReactElement => { + return ( + + }> + + } + /> + + } + /> + + + } + > + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + ); +}; + +export default KubernetesRoutes; diff --git a/App/FeatureSet/Dashboard/src/Routes/MetricsRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/MetricsRoutes.tsx index 0545fdcd3a..0a310d0cbe 100644 --- a/App/FeatureSet/Dashboard/src/Routes/MetricsRoutes.tsx +++ b/App/FeatureSet/Dashboard/src/Routes/MetricsRoutes.tsx @@ -9,6 +9,7 @@ import { Route as PageRoute, Routes } from "react-router-dom"; // Pages import MetricsPage from "../Pages/Metrics/Index"; +import MetricsDocumentationPage from "../Pages/Metrics/Documentation"; import MetricViewPage from "../Pages/Metrics/View/Index"; @@ -27,6 +28,15 @@ const MetricsRoutes: FunctionComponent = ( /> } /> + + } + /> {/* Metric View */} diff --git a/App/FeatureSet/Dashboard/src/Routes/SettingsRoutes.tsx b/App/FeatureSet/Dashboard/src/Routes/SettingsRoutes.tsx index 32374f018f..19f0c05334 100644 --- a/App/FeatureSet/Dashboard/src/Routes/SettingsRoutes.tsx +++ b/App/FeatureSet/Dashboard/src/Routes/SettingsRoutes.tsx @@ -64,6 +64,7 @@ import SettingsLogPipelineView from "../Pages/Settings/LogPipelineView"; import SettingsLogDropFilters from "../Pages/Settings/LogDropFilters"; import SettingsLogDropFilterView from "../Pages/Settings/LogDropFilterView"; import SettingsLogScrubRules from "../Pages/Settings/LogScrubRules"; +import SettingsMobileApps from "../Pages/Settings/MobileApps"; const SettingsRoutes: FunctionComponent = ( props: ComponentProps, @@ -92,6 +93,15 @@ const SettingsRoutes: FunctionComponent = ( /> } /> + + } + /> = ( /> } /> + + } + /> {/* Trace View */} diff --git a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/ExceptionsBreadcrumbs.ts b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/ExceptionsBreadcrumbs.ts index cfbba29944..0c806003b2 100644 --- a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/ExceptionsBreadcrumbs.ts +++ b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/ExceptionsBreadcrumbs.ts @@ -31,6 +31,11 @@ export function getExceptionsBreadcrumbs( "Exceptions", "View Exception", ]), + ...BuildBreadcrumbLinksByTitles(PageMap.EXCEPTIONS_DOCUMENTATION, [ + "Project", + "Exceptions", + "Documentation", + ]), }; return breadcrumpLinksMap[path]; } diff --git a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/KubernetesBreadcrumbs.ts b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/KubernetesBreadcrumbs.ts new file mode 100644 index 0000000000..f391349a9e --- /dev/null +++ b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/KubernetesBreadcrumbs.ts @@ -0,0 +1,67 @@ +import PageMap from "../PageMap"; +import { BuildBreadcrumbLinksByTitles } from "./Helper"; +import Dictionary from "Common/Types/Dictionary"; +import Link from "Common/Types/Link"; + +export function getKubernetesBreadcrumbs( + path: string, +): Array | undefined { + const breadcrumpLinksMap: Dictionary = { + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTERS, [ + "Project", + "Kubernetes", + "Clusters", + ]), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW, [ + "Project", + "Kubernetes", + "View Cluster", + ]), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_PODS, [ + "Project", + "Kubernetes", + "View Cluster", + "Pods", + ]), + ...BuildBreadcrumbLinksByTitles( + PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL, + ["Project", "Kubernetes", "View Cluster", "Pods", "Pod Detail"], + ), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_NODES, [ + "Project", + "Kubernetes", + "View Cluster", + "Nodes", + ]), + ...BuildBreadcrumbLinksByTitles( + PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL, + ["Project", "Kubernetes", "View Cluster", "Nodes", "Node Detail"], + ), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS, [ + "Project", + "Kubernetes", + "View Cluster", + "Events", + ]), + ...BuildBreadcrumbLinksByTitles( + PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE, + ["Project", "Kubernetes", "View Cluster", "Control Plane"], + ), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_DELETE, [ + "Project", + "Kubernetes", + "View Cluster", + "Delete Cluster", + ]), + ...BuildBreadcrumbLinksByTitles( + PageMap.KUBERNETES_CLUSTER_VIEW_DOCUMENTATION, + ["Project", "Kubernetes", "View Cluster", "Documentation"], + ), + ...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_DOCUMENTATION, [ + "Project", + "Kubernetes", + "Documentation", + ]), + }; + return breadcrumpLinksMap[path]; +} diff --git a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/MetricsBreadcrumbs.ts b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/MetricsBreadcrumbs.ts index 266c3ce2e4..37ff2e72a1 100644 --- a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/MetricsBreadcrumbs.ts +++ b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/MetricsBreadcrumbs.ts @@ -11,6 +11,11 @@ export function getMetricsBreadcrumbs(path: string): Array | undefined { "Metrics", "Metrics Explorer", ]), + ...BuildBreadcrumbLinksByTitles(PageMap.METRICS_DOCUMENTATION, [ + "Project", + "Metrics", + "Documentation", + ]), }; return breadcrumpLinksMap[path]; } diff --git a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/TracesBreadcrumbs.ts b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/TracesBreadcrumbs.ts index e08b180918..907c6640ab 100644 --- a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/TracesBreadcrumbs.ts +++ b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/TracesBreadcrumbs.ts @@ -11,6 +11,11 @@ export function getTracesBreadcrumbs(path: string): Array | undefined { "Traces", "Trace Explorer", ]), + ...BuildBreadcrumbLinksByTitles(PageMap.TRACES_DOCUMENTATION, [ + "Project", + "Traces", + "Documentation", + ]), }; return breadcrumpLinksMap[path]; } diff --git a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/index.ts b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/index.ts index 4d3b9a1074..0be2579617 100644 --- a/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/index.ts +++ b/App/FeatureSet/Dashboard/src/Utils/Breadcrumbs/index.ts @@ -11,6 +11,7 @@ export * from "./SettingsBreadcrumbs"; export * from "./MonitorGroupBreadcrumbs"; export * from "./ServiceBreadcrumbs"; export * from "./CodeRepositoryBreadcrumbs"; +export * from "./KubernetesBreadcrumbs"; export * from "./DashboardBreadCrumbs"; export * from "./AIAgentTasksBreadcrumbs"; export * from "./ExceptionsBreadcrumbs"; diff --git a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts index 3afefba04b..88c087f146 100644 --- a/App/FeatureSet/Dashboard/src/Utils/PageMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/PageMap.ts @@ -16,11 +16,13 @@ enum PageMap { METRICS_ROOT = "METRICS_ROOT", METRICS = "METRICS", METRIC_VIEW = "METRIC_VIEW", + METRICS_DOCUMENTATION = "METRICS_DOCUMENTATION", // Traces (standalone product) TRACES_ROOT = "TRACES_ROOT", TRACES = "TRACES", TRACE_VIEW = "TRACE_VIEW", + TRACES_DOCUMENTATION = "TRACES_DOCUMENTATION", HOME = "HOME", HOME_NOT_OPERATIONAL_MONITORS = "HOME_NOT_OPERATIONAL_MONITORS", @@ -214,6 +216,21 @@ enum PageMap { SERVICE_VIEW_CODE_REPOSITORIES = "SERVICE_VIEW_CODE_REPOSITORIES", SERVICE_DEPENDENCY_GRAPH = "SERVICE_DEPENDENCY_GRAPH", + // Kubernetes (standalone product) + KUBERNETES_ROOT = "KUBERNETES_ROOT", + KUBERNETES_CLUSTERS = "KUBERNETES_CLUSTERS", + KUBERNETES_CLUSTER_VIEW = "KUBERNETES_CLUSTER_VIEW", + KUBERNETES_CLUSTER_VIEW_PODS = "KUBERNETES_CLUSTER_VIEW_PODS", + KUBERNETES_CLUSTER_VIEW_POD_DETAIL = "KUBERNETES_CLUSTER_VIEW_POD_DETAIL", + KUBERNETES_CLUSTER_VIEW_NODES = "KUBERNETES_CLUSTER_VIEW_NODES", + KUBERNETES_CLUSTER_VIEW_NODE_DETAIL = "KUBERNETES_CLUSTER_VIEW_NODE_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", + KUBERNETES_CLUSTER_VIEW_SETTINGS = "KUBERNETES_CLUSTER_VIEW_SETTINGS", + KUBERNETES_CLUSTER_VIEW_DOCUMENTATION = "KUBERNETES_CLUSTER_VIEW_DOCUMENTATION", + KUBERNETES_DOCUMENTATION = "KUBERNETES_DOCUMENTATION", + // Code Repository CODE_REPOSITORY_ROOT = "CODE_REPOSITORY_ROOT", CODE_REPOSITORY = "CODE_REPOSITORY", @@ -432,6 +449,9 @@ enum PageMap { SETTINGS_NOTIFICATION_LOGS = "SETTINGS_NOTIFICATION_LOGS", + // Mobile Apps + SETTINGS_MOBILE_APPS = "SETTINGS_MOBILE_APPS", + // AI Logs SETTINGS_AI_LOGS = "SETTINGS_AI_LOGS", @@ -451,6 +471,7 @@ enum PageMap { EXCEPTIONS_ARCHIVED = "EXCEPTIONS_ARCHIVED", EXCEPTIONS_VIEW_ROOT = "EXCEPTIONS_VIEW_ROOT", EXCEPTIONS_VIEW = "EXCEPTIONS_VIEW", + EXCEPTIONS_DOCUMENTATION = "EXCEPTIONS_DOCUMENTATION", // Push Logs in resource views } diff --git a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts index 17330cf2f9..3a182d63e9 100644 --- a/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts +++ b/App/FeatureSet/Dashboard/src/Utils/RouteMap.ts @@ -59,6 +59,20 @@ export const CodeRepositoryRoutePath: Dictionary = { [PageMap.CODE_REPOSITORY_VIEW_SERVICES]: `${RouteParams.ModelID}/services`, }; +export const KubernetesRoutePath: Dictionary = { + [PageMap.KUBERNETES_CLUSTER_VIEW]: `${RouteParams.ModelID}`, + [PageMap.KUBERNETES_CLUSTER_VIEW_PODS]: `${RouteParams.ModelID}/pods`, + [PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL]: `${RouteParams.ModelID}/pods/${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_EVENTS]: `${RouteParams.ModelID}/events`, + [PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE]: `${RouteParams.ModelID}/control-plane`, + [PageMap.KUBERNETES_CLUSTER_VIEW_DELETE]: `${RouteParams.ModelID}/delete`, + [PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS]: `${RouteParams.ModelID}/settings`, + [PageMap.KUBERNETES_CLUSTER_VIEW_DOCUMENTATION]: `${RouteParams.ModelID}/documentation`, + [PageMap.KUBERNETES_DOCUMENTATION]: `documentation`, +}; + export const WorkflowRoutePath: Dictionary = { [PageMap.WORKFLOWS_LOGS]: "logs", [PageMap.WORKFLOWS_VARIABLES]: "variables", @@ -86,12 +100,14 @@ export const LogsRoutePath: Dictionary = { export const MetricsRoutePath: Dictionary = { [PageMap.METRICS]: "", [PageMap.METRIC_VIEW]: "view", + [PageMap.METRICS_DOCUMENTATION]: "documentation", }; // Traces product routes export const TracesRoutePath: Dictionary = { [PageMap.TRACES]: "", [PageMap.TRACE_VIEW]: `view/${RouteParams.ModelID}`, + [PageMap.TRACES_DOCUMENTATION]: "documentation", }; export const ExceptionsRoutePath: Dictionary = { @@ -101,6 +117,7 @@ export const ExceptionsRoutePath: Dictionary = { [PageMap.EXCEPTIONS_ARCHIVED]: "archived", [PageMap.EXCEPTIONS_VIEW_ROOT]: "", [PageMap.EXCEPTIONS_VIEW]: `${RouteParams.ModelID}`, + [PageMap.EXCEPTIONS_DOCUMENTATION]: "documentation", }; export const DashboardsRoutePath: Dictionary = { @@ -299,6 +316,7 @@ export const SettingsRoutePath: Dictionary = { [PageMap.SETTINGS_DANGERZONE]: "danger-zone", [PageMap.SETTINGS_NOTIFICATION_SETTINGS]: "notification-settings", [PageMap.SETTINGS_NOTIFICATION_LOGS]: "notification-logs", + [PageMap.SETTINGS_MOBILE_APPS]: "mobile-apps", [PageMap.SETTINGS_AI_LOGS]: "ai-logs", [PageMap.SETTINGS_APIKEYS]: `api-keys`, [PageMap.SETTINGS_APIKEY_VIEW]: `api-keys/${RouteParams.ModelID}`, @@ -1465,6 +1483,82 @@ const RouteMap: Dictionary = { }`, ), + // Kubernetes + + [PageMap.KUBERNETES_ROOT]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/*`, + ), + + [PageMap.KUBERNETES_CLUSTERS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_PODS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PODS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_NODES]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NODES] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_DELETE]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DELETE] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS] + }`, + ), + + [PageMap.KUBERNETES_CLUSTER_VIEW_DOCUMENTATION]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DOCUMENTATION] + }`, + ), + + [PageMap.KUBERNETES_DOCUMENTATION]: new Route( + `/dashboard/${RouteParams.ProjectID}/kubernetes/${ + KubernetesRoutePath[PageMap.KUBERNETES_DOCUMENTATION] + }`, + ), + // Dashboards [PageMap.DASHBOARDS_ROOT]: new Route( @@ -1981,6 +2075,12 @@ const RouteMap: Dictionary = { }`, ), + [PageMap.METRICS_DOCUMENTATION]: new Route( + `/dashboard/${RouteParams.ProjectID}/metrics/${ + MetricsRoutePath[PageMap.METRICS_DOCUMENTATION] + }`, + ), + // Traces Product Routes [PageMap.TRACES_ROOT]: new Route( `/dashboard/${RouteParams.ProjectID}/traces/*`, @@ -1994,6 +2094,12 @@ const RouteMap: Dictionary = { }`, ), + [PageMap.TRACES_DOCUMENTATION]: new Route( + `/dashboard/${RouteParams.ProjectID}/traces/${ + TracesRoutePath[PageMap.TRACES_DOCUMENTATION] + }`, + ), + // User Settings Routes [PageMap.USER_SETTINGS_ROOT]: new Route( `/dashboard/${RouteParams.ProjectID}/user-settings/*`, @@ -2105,6 +2211,12 @@ const RouteMap: Dictionary = { }`, ), + [PageMap.SETTINGS_MOBILE_APPS]: new Route( + `/dashboard/${RouteParams.ProjectID}/settings/${ + SettingsRoutePath[PageMap.SETTINGS_MOBILE_APPS] + }`, + ), + [PageMap.SETTINGS_AI_LOGS]: new Route( `/dashboard/${RouteParams.ProjectID}/settings/${ SettingsRoutePath[PageMap.SETTINGS_AI_LOGS] @@ -2468,6 +2580,12 @@ const RouteMap: Dictionary = { ExceptionsRoutePath[PageMap.EXCEPTIONS_VIEW] }`, ), + + [PageMap.EXCEPTIONS_DOCUMENTATION]: new Route( + `/dashboard/${RouteParams.ProjectID}/exceptions/${ + ExceptionsRoutePath[PageMap.EXCEPTIONS_DOCUMENTATION] + }`, + ), }; export class RouteUtil { diff --git a/Common/Models/DatabaseModels/Index.ts b/Common/Models/DatabaseModels/Index.ts index 53ed9ba87b..11bfd86ec8 100644 --- a/Common/Models/DatabaseModels/Index.ts +++ b/Common/Models/DatabaseModels/Index.ts @@ -1,5 +1,6 @@ import AcmeCertificate from "./AcmeCertificate"; import AcmeChallenge from "./AcmeChallenge"; +import KubernetesCluster from "./KubernetesCluster"; // API Keys import ApiKey from "./ApiKey"; import ApiKeyPermission from "./ApiKeyPermission"; @@ -499,6 +500,8 @@ const AllModelTypes: Array<{ ProjectSCIM, ProjectSCIMLog, StatusPageSCIMLog, + + KubernetesCluster, ]; const modelTypeMap: { [key: string]: { new (): BaseModel } } = {}; diff --git a/Common/Models/DatabaseModels/KubernetesCluster.ts b/Common/Models/DatabaseModels/KubernetesCluster.ts new file mode 100644 index 0000000000..6201ff5e07 --- /dev/null +++ b/Common/Models/DatabaseModels/KubernetesCluster.ts @@ -0,0 +1,640 @@ +import Label from "./Label"; +import Project from "./Project"; +import User from "./User"; +import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel"; +import Route from "../../Types/API/Route"; +import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl"; +import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl"; +import AccessControlColumn from "../../Types/Database/AccessControlColumn"; +import ColumnLength from "../../Types/Database/ColumnLength"; +import ColumnType from "../../Types/Database/ColumnType"; +import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint"; +import EnableDocumentation from "../../Types/Database/EnableDocumentation"; +import EnableWorkflow from "../../Types/Database/EnableWorkflow"; +import SlugifyColumn from "../../Types/Database/SlugifyColumn"; +import TableColumn from "../../Types/Database/TableColumn"; +import TableColumnType from "../../Types/Database/TableColumnType"; +import TableMetadata from "../../Types/Database/TableMetadata"; +import TenantColumn from "../../Types/Database/TenantColumn"; +import UniqueColumnBy from "../../Types/Database/UniqueColumnBy"; +import IconProp from "../../Types/Icon/IconProp"; +import ObjectID from "../../Types/ObjectID"; +import Permission from "../../Types/Permission"; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, +} from "typeorm"; + +@AccessControlColumn("labels") +@EnableDocumentation() +@TenantColumn("projectId") +@TableAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + delete: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.DeleteKubernetesCluster, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], +}) +@EnableWorkflow({ + create: true, + delete: true, + update: true, + read: true, +}) +@CrudApiEndpoint(new Route("/kubernetes-cluster")) +@SlugifyColumn("name", "slug") +@TableMetadata({ + tableName: "KubernetesCluster", + singularName: "Kubernetes Cluster", + pluralName: "Kubernetes Clusters", + icon: IconProp.Cube, + tableDescription: + "Kubernetes Clusters that are being monitored in this project. Each cluster is auto-discovered when the OneUptime kubernetes-agent sends metrics, or can be manually registered.", +}) +@Entity({ + name: "KubernetesCluster", +}) +export default class KubernetesCluster extends BaseModel { + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "projectId", + type: TableColumnType.Entity, + modelType: Project, + title: "Project", + description: "Relation to Project Resource in which this object belongs", + }) + @ManyToOne( + () => { + return Project; + }, + { + eager: false, + nullable: true, + onDelete: "CASCADE", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "projectId" }) + public project?: Project = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @Index() + @TableColumn({ + type: TableColumnType.ObjectID, + required: true, + canReadOnRelationQuery: true, + title: "Project ID", + description: "ID of your OneUptime Project in which this object belongs", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: false, + transformer: ObjectID.getDatabaseTransformer(), + }) + public projectId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: true, + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "Name", + description: "Friendly name for this Kubernetes cluster", + example: "production-us-east", + }) + @Column({ + nullable: false, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + @UniqueColumnBy("projectId") + public name?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + required: true, + unique: true, + type: TableColumnType.Slug, + computed: true, + title: "Slug", + description: "Friendly globally unique name for your object", + }) + @Column({ + nullable: false, + type: ColumnType.Slug, + length: ColumnLength.Slug, + }) + public slug?: string = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.LongText, + canReadOnRelationQuery: true, + title: "Description", + description: "Friendly description for this Kubernetes cluster", + example: "Production cluster running in US East region on EKS", + }) + @Column({ + nullable: true, + type: ColumnType.LongText, + length: ColumnLength.LongText, + }) + public description?: string = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @Index() + @TableColumn({ + required: true, + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "Cluster Identifier", + description: + "Unique identifier for this cluster, sourced from the k8s.cluster.name OTel resource attribute", + example: "production-us-east-1", + }) + @Column({ + nullable: false, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + }) + @UniqueColumnBy("projectId") + public clusterIdentifier?: string = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "Provider", + description: + "Cloud provider or platform running this cluster (EKS, GKE, AKS, self-managed, unknown)", + example: "EKS", + }) + @Column({ + nullable: true, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + default: "unknown", + }) + public provider?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.ShortText, + canReadOnRelationQuery: true, + title: "OTel Collector Status", + description: + "Connection status of the OTel Collector agent (connected or disconnected)", + example: "connected", + }) + @Column({ + nullable: true, + type: ColumnType.ShortText, + length: ColumnLength.ShortText, + default: "disconnected", + }) + public otelCollectorStatus?: string = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.Date, + canReadOnRelationQuery: true, + title: "Last Seen At", + description: "When metrics were last received from this cluster", + }) + @Column({ + nullable: true, + type: ColumnType.Date, + }) + public lastSeenAt?: Date = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + type: TableColumnType.Number, + title: "Node Count", + description: "Cached count of nodes in this cluster", + }) + @Column({ + type: ColumnType.Number, + nullable: true, + default: 0, + }) + public nodeCount?: number = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + type: TableColumnType.Number, + title: "Pod Count", + description: "Cached count of pods in this cluster", + }) + @Column({ + type: ColumnType.Number, + nullable: true, + default: 0, + }) + public podCount?: number = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + type: TableColumnType.Number, + title: "Namespace Count", + description: "Cached count of namespaces in this cluster", + }) + @Column({ + type: ColumnType.Number, + nullable: true, + default: 0, + }) + public namespaceCount?: number = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "createdByUserId", + type: TableColumnType.Entity, + modelType: User, + title: "Created by User", + description: + "Relation to User who created this object (if this object was created by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "createdByUserId" }) + public createdByUser?: User = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Created by User ID", + description: + "User ID who created this object (if this object was created by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public createdByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + manyToOneRelationColumn: "deletedByUserId", + type: TableColumnType.Entity, + title: "Deleted by User", + modelType: User, + description: + "Relation to User who deleted this object (if this object was deleted by a User)", + }) + @ManyToOne( + () => { + return User; + }, + { + cascade: false, + eager: false, + nullable: true, + onDelete: "SET NULL", + orphanedRowAction: "nullify", + }, + ) + @JoinColumn({ name: "deletedByUserId" }) + public deletedByUser?: User = undefined; + + @ColumnAccessControl({ + create: [], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [], + }) + @TableColumn({ + type: TableColumnType.ObjectID, + title: "Deleted by User ID", + description: + "User ID who deleted this object (if this object was deleted by a User)", + }) + @Column({ + type: ColumnType.ObjectID, + nullable: true, + transformer: ObjectID.getDatabaseTransformer(), + }) + public deletedByUserId?: ObjectID = undefined; + + @ColumnAccessControl({ + create: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.CreateKubernetesCluster, + ], + read: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.ReadKubernetesCluster, + Permission.ReadAllProjectResources, + ], + update: [ + Permission.ProjectOwner, + Permission.ProjectAdmin, + Permission.ProjectMember, + Permission.EditKubernetesCluster, + ], + }) + @TableColumn({ + required: false, + type: TableColumnType.EntityArray, + modelType: Label, + title: "Labels", + description: + "Relation to Labels Array where this object is categorized in.", + }) + @ManyToMany( + () => { + return Label; + }, + { eager: false }, + ) + @JoinTable({ + name: "KubernetesClusterLabel", + inverseJoinColumn: { + name: "labelId", + referencedColumnName: "_id", + }, + joinColumn: { + name: "kubernetesClusterId", + referencedColumnName: "_id", + }, + }) + public labels?: Array