diff --git a/src/features/novus.tsx b/src/features/novus.tsx index 646206b..cc3cb53 100644 --- a/src/features/novus.tsx +++ b/src/features/novus.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, type ReactNode } from "react"; import { useAuth } from "react-oidc-context"; import { useEnv } from "../components/env.tsx"; @@ -12,20 +12,14 @@ import { ThresholdsConfigBuilder, ThresholdsMode, MappingType, - defaultValueMappingResult, FieldColorModeId, } from "@grafana/grafana-foundation-sdk/dashboard"; -import { DataqueryBuilder as PrometheusDataqueryBuilder, PromQueryFormat } from "@grafana/grafana-foundation-sdk/prometheus"; +import { DataqueryBuilder as PrometheusDataqueryBuilder } from "@grafana/grafana-foundation-sdk/prometheus"; import { PanelBuilder as StatsPanelBuilder } from "@grafana/grafana-foundation-sdk/stat"; - -import { - PanelBuilder as TextPanelBuilder, - TextMode, -} from "@grafana/grafana-foundation-sdk/text"; +import { PanelBuilder as TextPanelBuilder, TextMode } from "@grafana/grafana-foundation-sdk/text"; import { usePersistentState } from "../lib/usePersistentState.ts"; - import { queryPrometheus } from "../lib/prometheusQuerier.ts"; export const FeatureID = "novus"; @@ -33,8 +27,23 @@ export const FeatureName = "Novus Runtime Information"; export const FeatureIcon = "https://raw.githubusercontent.com/kubernetes/kubernetes/refs/heads/master/logo/logo.svg"; -// Boilerplate library panel definitions for Novus. -// Replace UIDs with the actual library panel UIDs from your Grafana instance. +// ---- Types ---- + +interface Resources { + deployments: string[]; + statefulsets: string[]; + nginxHosts: string[]; + haproxyIngresses: string[]; +} + +interface FormData { + runtime: string; + showRecommendations: boolean; + resources: Resources; +} + +// ---- Constants ---- + const NOVUS_LIBRARY_PANELS = [ { name: "Novus Deployment timeline", uid: "cffw1fuyo9x4wb" }, { name: "Novus CPU Usage", uid: "affw0w9ciuw3kf" }, @@ -44,6 +53,13 @@ const NOVUS_LIBRARY_PANELS = [ { name: "Memory Requests Recommendations", uid: "afio83xesz08wf", height: 11, span: 24, enabledBy: "showRecommendations" }, ]; +const EMPTY_RESOURCES: Resources = { + deployments: [], + statefulsets: [], + nginxHosts: [], + haproxyIngresses: [], +}; + // A banner aded before every other panel to introduce people to that specific // section. const NOVUS_BANNER = new TextPanelBuilder() @@ -65,289 +81,289 @@ const NOVUS_BANNER = new TextPanelBuilder() `); -export function Component({ goBack, goForward, setDashboardPanels }) { - const [errors, setErrors] = useState({}); - const [runtimes, setRuntimes] = useState([]); - const [teams, setTeams] = useState([]); - const [selectedTeam, setSelectedTeam] = useState(""); - const [deployments, setDeployments] = useState([]); - const [statefulsets, setStatefulsets] = useState([]); - const [haproxyIngresses, setHaproxyIngresses] = useState([]); - const [nginxIngresses, setNginxIngresses] = useState([]); - const auth = useAuth(); - const env = useEnv(); - const [formData, setFormData] = usePersistentState("feat_novus_formData", { - runtime: "", - showRecommendations: false, - resources: { - deployments: [], - statefulsets: [], - nginxHosts: [], - haproxyIngresses: [], - } +// ---- Panel builders (pure, no React) ---- + +const normalizeDesc = (s: string) => s.replace(/\s+/g, " ").trim(); + +function buildVariables(runtime: string) { + const podFilter = new AdHocVariableBuilder("novus_pod_filter") + .hide(VariableHide.HideVariable) + .datasource({ uid: "$prometheus" }) + .build(); + podFilter.filters = [{ key: "pod", operator: "!~", value: "novus-.*" }]; + + return [ + new ConstantVariableBuilder("namespace") + .label("Novus Runtime / namespace") + .value(runtime), + { build: () => podFilter }, + ]; +} + +function buildOverviewPanels(resources: Resources) { + const panels: any[] = [ + new StatsPanelBuilder() + .title("Novus: Unready Pods") + .thresholds( + new ThresholdsConfigBuilder() + .mode(ThresholdsMode.Absolute) + .steps([{ value: 1.0, color: "red" }]), + ) + .description(normalizeDesc(` + Kubernetes Pods that are either pending, with errors or unknown state. + Might signal an upgrade or errors. If this number is not 0 and the app + has errors, the issue might involve container platform or a botched release. + `)) + .height(4) + .interval("5m") + .withTarget( + new PrometheusDataqueryBuilder() + .datasource({ uid: "$prometheus" }) + .expr(`sum(kube_pod_status_phase{phase=~"(Failed|Unknown|Pending)", namespace="$namespace"})`) + .instant(), + ), + ]; + + const deployRegex = resources.deployments.join("|"); + if (deployRegex) { + panels.push( + new StatsPanelBuilder() + .title("Novus Deployments") + .description("Kubernetes deployments statuses") + .height(4) + .withTarget( + new PrometheusDataqueryBuilder() + .datasource({ uid: "$prometheus" }) + .expr(`max(kube_deployment_status_condition{namespace=~"$namespace", condition="Available", deployment=~"${deployRegex}", status="true"}) by (deployment)`) + .legendFormat("{{deployment}}") + .instant() + ) + .wideLayout(false) + .mappings([{ + type: MappingType.ValueToText, + options: { + "0": { text: "Unhealthy", color: "red" }, + "1": { text: "Healthy", color: "green" }, + }, + }]) + ); + } + + resources.nginxHosts.forEach((nginxHost) => { + const promTarget = (expr: string, legend: string) => + new PrometheusDataqueryBuilder() + .datasource({ uid: "$prometheus" }) + .expr(expr) + .legendFormat(legend); + + panels.push( + new StatsPanelBuilder() + .title(nginxHost) + .description("Overview of response codes for this ingress host over the dashboard time range") + .height(4) + .withTarget(promTarget( + `floor(sum(increase(nginx_ingress_controller_requests{namespace="$namespace", host="${nginxHost}", status=~"[1-3].."}[$__range])))`, + "Successes" + )) + .withTarget(promTarget( + `floor(sum(increase(nginx_ingress_controller_requests{namespace="$namespace", host="${nginxHost}", status=~"4.."}[$__range])))`, + "4xx's" + )) + .withTarget(promTarget( + `floor(sum(increase(nginx_ingress_controller_requests{namespace="$namespace", host="${nginxHost}", status=~"5.."}[$__range])))`, + "5xx's" + )) + .overrideByQuery("A", [{ id: "color", value: { mode: FieldColorModeId.Fixed, fixedColor: "green" } }]) + .overrideByQuery("B", [{ id: "color", value: { mode: FieldColorModeId.Fixed, fixedColor: "orange" } }]) + .overrideByQuery("C", [{ id: "color", value: { mode: FieldColorModeId.Fixed, fixedColor: "red" } }]) + ); }); - const host = env?.["BUN_PUBLIC_PROMETHEUS_ENDPOINT"]; + return panels; +} - const getTeams = () => { - return queryPrometheus( - host, - 'group(kube_namespace_labels{label_novus_legogroup_io_namespace_type="managed-customer-runtime", label_novus_legogroup_io_team_name!=""}) by (label_novus_legogroup_io_team_name)', - auth?.user?.id_token, - ).then((result) => { - return result.data.result.map( - (item: any) => item.metric.label_novus_legogroup_io_team_name, - ); - }); - }; +function buildPanels(showRecommendations: boolean) { + const panels = NOVUS_LIBRARY_PANELS.filter((p) => !p.enabledBy || showRecommendations); + return [NOVUS_BANNER].concat( + panels.map((p) => + new PanelBuilder() + .title(p.name) + .height(p.height || 8) + .span(p.span || 12) + .libraryPanel({ name: p.name, uid: p.uid }) + ) + ); +} - const getAllRuntimes = () => { - let query = `group(kube_namespace_labels{label_novus_legogroup_io_namespace_type="managed-customer-runtime", label_novus_legogroup_io_team_name!=""}) by (namespace, label_novus_legogroup_io_team_name)`; - return queryPrometheus(host, query, auth?.user?.id_token).then((result) => { - return result.data.result.map((item: any) => ({ - namespace: item.metric.namespace, - team: item.metric.label_novus_legogroup_io_team_name, - })); - }); - }; +// ---- Sub-components ---- - const getFirstTeam = () => { - let query = `group(novus_user_team_membership{user="${auth?.user?.profile?.upn.toLowerCase()}"}) by (team)`; - return queryPrometheus(host, query, auth?.user?.id_token).then((result) => { - return result.data.result[0].metric.team; - }); - }; +function K8sIcon({ type, height }: { type: string; height: number }) { + return ( + {type} + ); +} - const [allTeams, setAllTeams] = useState>( - Promise.resolve([]), +interface ResourceListProps { + title: string; + icon: ReactNode; + resources: string[]; + selected: string[]; + onToggle: (resource: string) => void; +} + +function ResourceList({ title, icon, resources, selected, onToggle }: ResourceListProps) { + if (resources.length === 0) return null; + return ( +
+

{icon} {title}

+ +
); - const [allRuntimes, setAllRuntimes] = useState< - Promise<{ namespace: string; team: string }[]> - >(Promise.resolve([])); +} - useEffect(() => { - if (!auth?.isAuthenticated) return; - setAllTeams(getTeams()); - setAllRuntimes(getAllRuntimes()); - }, [auth?.isAuthenticated]); +// ---- Data hook ---- - useEffect(() => { - getFirstTeam().then((res) => setSelectedTeam(res)); - }, [auth?.isAuthenticated]); - - const searchTeams = (event) => { - let ts: any[] = []; - allTeams.then((items: Array) => { - items.forEach((team) => { - if (team.toLowerCase().includes(event.query.toLowerCase())) { - ts.push(team); - } - }); - setTeams(ts); - }); - }; +interface NovusData { + allTeams: string[]; + allRuntimes: { namespace: string; team: string }[]; + selectedTeam: string; + setSelectedTeam: (team: string) => void; + deployments: string[]; + statefulsets: string[]; + nginxIngresses: string[]; +} - const searchRuntimes = (event) => { - let rts: any[] = []; - allRuntimes.then((items: Array<{ namespace: string; team: string }>) => { - items.forEach((r) => { - if ( - r.namespace.toLowerCase().includes(event.query.toLowerCase()) && - (!selectedTeam || r.team === selectedTeam) - ) { - rts.push(r.namespace); - } - }); - setRuntimes(rts); - }); - }; +function useNovusData( + host: string | undefined, + token: string | undefined, + upn: string | undefined, + isAuthenticated: boolean, + runtime: string, +): NovusData { + const [allTeams, setAllTeams] = useState([]); + const [allRuntimes, setAllRuntimes] = useState<{ namespace: string; team: string }[]>([]); + const [selectedTeam, setSelectedTeam] = useState(""); + const [deployments, setDeployments] = useState([]); + const [statefulsets, setStatefulsets] = useState([]); + const [nginxIngresses, setNginxIngresses] = useState([]); + + useEffect(() => { + if (!isAuthenticated) return; - const fetchDeployments = () => { queryPrometheus( host, - `group(kube_deployment_spec_replicas{namespace="${formData.runtime}"}) by (deployment)`, - auth?.user?.id_token, - ).then((result) => { - setDeployments( - result.data.result.map((item: any) => item.metric.deployment), - ); - }); - } - const fetchIngresses = () => { + 'group(kube_namespace_labels{label_novus_legogroup_io_namespace_type="managed-customer-runtime", label_novus_legogroup_io_team_name!=""}) by (label_novus_legogroup_io_team_name)', + token, + ).then((r) => setAllTeams(r.data.result.map((i: any) => i.metric.label_novus_legogroup_io_team_name))); + queryPrometheus( host, - `group(nginx_ingress_controller_requests{namespace="${formData.runtime}"}) by (host)`, - auth?.user?.id_token, - ).then((result) => { - setNginxIngresses( - result.data.result.map((item: any) => item.metric.host), - ); - }); - } - const fetchStatefulsets = () => { + `group(kube_namespace_labels{label_novus_legogroup_io_namespace_type="managed-customer-runtime", label_novus_legogroup_io_team_name!=""}) by (namespace, label_novus_legogroup_io_team_name)`, + token, + ).then((r) => setAllRuntimes(r.data.result.map((i: any) => ({ + namespace: i.metric.namespace, + team: i.metric.label_novus_legogroup_io_team_name, + })))); + queryPrometheus( host, - `group(kube_statefulset_status_replicas{namespace="${formData.runtime}"}) by (statefulset)`, - auth?.user?.id_token, - ).then((result) => { - setStatefulsets( - result.data.result.map((item: any) => item.metric.statefulset), - ); - }); - } + `group(novus_user_team_membership{user="${upn?.toLowerCase()}"}) by (team)`, + token, + ).then((r) => setSelectedTeam(r.data.result[0]?.metric.team ?? "")); + }, [isAuthenticated]); useEffect(() => { - if (!auth?.isAuthenticated || formData.runtime === "") return; - formData.resources = {} - fetchDeployments(); - fetchIngresses(); - fetchStatefulsets(); - }, [host, auth?.isAuthenticated, formData.runtime]); - - const genVariables = () => { - const podFilter = new AdHocVariableBuilder("novus_pod_filter") - .hide(VariableHide.HideVariable) - .datasource({ uid: "$prometheus" }) - .build(); - podFilter.filters = [{ key: "pod", operator: "!~", value: "novus-.*" }]; - - return [ - new ConstantVariableBuilder("namespace") - .label("Novus Runtime / namespace") - .value(formData.runtime), - { build: () => podFilter }, - ]; - }; + if (!isAuthenticated || !runtime) return; - const genOverviewPanels = () => { - let overviewPanels = []; + queryPrometheus(host, `group(kube_deployment_spec_replicas{namespace="${runtime}"}) by (deployment)`, token) + .then((r) => setDeployments(r.data.result.map((i: any) => i.metric.deployment))); - overviewPanels = overviewPanels.concat([ - // Shows Pods Pending, errors - new StatsPanelBuilder() - .title("Novus: Unready Pods") - .thresholds( - new ThresholdsConfigBuilder() - .mode(ThresholdsMode.Absolute) - .steps([{ value: 1.0, color: "red" }]), - ) - .description( - ` - Kubernetes Pods that are either pending, with errors or uknown - state. Might signal an upgrade or errors. If this number is not 0, - and the app has errors, then the issue might involve container - platform, or a botched released! Oppsie! - ` - .replace(/\s+/g, " ") - .trim(), - ) - .height(4) - .interval("5m") - .withTarget( - new PrometheusDataqueryBuilder() - .datasource({ uid: "$prometheus" }) - .expr( - `sum(kube_pod_status_phase{phase=~"(Failed|Unknown|Pending)", namespace="$namespace"})`, - ) - .instant(), - ), - ]); - - let deployRegex = formData?.resources?.deployments?.join("|") - - if (deployRegex !== "") { - overviewPanels.push( - new StatsPanelBuilder() - .title("Novus Deployments") - .description( - ` - Kubernetes deployments statuses - `.replace(/\s+/g, " ") - .trim() - ) - .height(4) - .withTarget( - new PrometheusDataqueryBuilder() - .datasource({ uid: "$prometheus" }) - .expr( - `max(kube_deployment_status_condition{namespace=~"$namespace", condition="Available", deployment=~"${deployRegex}", status="true"}) by (deployment)` - ) - .legendFormat("{{deployment}}") - .instant() - ) - .wideLayout(false) - .mappings([ - { - type: MappingType.ValueToText, - options: { - "0": { "text": "Unhealthy", "color": "red"}, - "1": { "text": "Healthy", "color": "green"} - } - } - ]) - - ) - } - - // let nginxHostRegex = formData?.resources?.nginxHosts.join("|") - - formData?.resources?.nginxHosts?.forEach((host) => { - overviewPanels.push( - new StatsPanelBuilder() - .title(host) - .description("An overview of the response codes returned by the ingresses with this host over the time frame selected in the dashboard") - .height(4) - .withTarget( - new PrometheusDataqueryBuilder() - .datasource({ uid: "$prometheus" }) - .expr( - `floor(sum(increase(nginx_ingress_controller_requests{namespace="$namespace", host="${host}", status=~"[1-3].."}[$__range])))` - ) - .legendFormat("Successes") - ) - .withTarget( - new PrometheusDataqueryBuilder() - .datasource({ uid: "$prometheus" }) - .expr( - `floor(sum(increase(nginx_ingress_controller_requests{namespace="$namespace", host="${host}", status=~"4.."}[$__range])))` - ) - .legendFormat("4xx's") - ) - .withTarget( - new PrometheusDataqueryBuilder() - .datasource({ uid: "$prometheus" }) - .expr( - `floor(sum(increase(nginx_ingress_controller_requests{namespace="$namespace", host="${host}", status=~"5.."}[$__range])))` - ) - .legendFormat("5xx's") - ) - .overrideByQuery("A", [{id: "color", value: {mode: FieldColorModeId.Fixed, fixedColor: "green"}}]) - .overrideByQuery("B", [{id: "color", value: {mode: FieldColorModeId.Fixed, fixedColor: "orange"}}]) - .overrideByQuery("C", [{id: "color", value: {mode: FieldColorModeId.Fixed, fixedColor: "red"}}]) - ) - }) + queryPrometheus(host, `group(nginx_ingress_controller_requests{namespace="${runtime}"}) by (host)`, token) + .then((r) => setNginxIngresses(r.data.result.map((i: any) => i.metric.host))); + + queryPrometheus(host, `group(kube_statefulset_status_replicas{namespace="${runtime}"}) by (statefulset)`, token) + .then((r) => setStatefulsets(r.data.result.map((i: any) => i.metric.statefulset))); + }, [host, isAuthenticated, runtime]); + + return { allTeams, allRuntimes, selectedTeam, setSelectedTeam, deployments, statefulsets, nginxIngresses }; +} + +// ---- Component ---- + +interface Props { + goBack?: () => void; + goForward?: () => void; + setDashboardPanels: (id: string, overview: any[], panels: any[], variables: any[]) => void; +} + +export function Component({ goBack, goForward, setDashboardPanels }: Props) { + const auth = useAuth(); + const env = useEnv(); + const [errors, setErrors] = useState>({}); + const [teams, setTeams] = useState([]); + const [runtimes, setRuntimes] = useState([]); + const [formData, setFormData] = usePersistentState("feat_novus_formData", { + runtime: "", + showRecommendations: false, + resources: EMPTY_RESOURCES, + }); + + const host = env?.["BUN_PUBLIC_PROMETHEUS_ENDPOINT"]; + const token = auth?.user?.id_token; + const upn = auth?.user?.profile?.upn as string | undefined; + + const { allTeams, allRuntimes, selectedTeam, setSelectedTeam, deployments, statefulsets, nginxIngresses } = + useNovusData(host, token, upn, !!auth?.isAuthenticated, formData.runtime); + + // Clear selected resources when runtime changes + useEffect(() => { + if (!formData.runtime) return; + setFormData({ ...formData, resources: EMPTY_RESOURCES }); + }, [formData.runtime]); - return overviewPanels; + const searchTeams = (event: { query: string }) => { + const q = event.query.toLowerCase(); + setTeams(allTeams.filter((t) => t.toLowerCase().includes(q))); }; - const genPanels = () => { - const panels = NOVUS_LIBRARY_PANELS.filter((panel) => - !panel.enabledBy || formData[panel.enabledBy] + const searchRuntimes = (event: { query: string }) => { + const q = event.query.toLowerCase(); + setRuntimes( + allRuntimes + .filter((r) => r.namespace.toLowerCase().includes(q) && (!selectedTeam || r.team === selectedTeam)) + .map((r) => r.namespace) ); - return [NOVUS_BANNER].concat(panels.map((panel) => - new PanelBuilder() - .title(panel.name) - .height(panel.height || 8) - .span(panel.span || 12) - .libraryPanel({ name: panel.name, uid: panel.uid }) - )); + }; + + const toggleResource = (key: keyof Resources, resource: string) => { + const arr = formData.resources[key]; + setFormData({ + ...formData, + resources: { + ...formData.resources, + [key]: arr.includes(resource) ? arr.filter((r) => r !== resource) : [...arr, resource], + }, + }); }; const validate = () => { - const newErrors = {}; - if (formData.runtime.trim().length === 0) { - newErrors.runtime = "A runtime name is required"; - } + const newErrors: Record = {}; + if (!formData.runtime.trim()) newErrors.runtime = "A runtime name is required"; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; @@ -356,63 +372,17 @@ export function Component({ goBack, goForward, setDashboardPanels }) { if (!validate()) return; setDashboardPanels( FeatureID, - genOverviewPanels(), - genPanels(), - genVariables(), + buildOverviewPanels(formData.resources), + buildPanels(formData.showRecommendations), + buildVariables(formData.runtime), ); - goForward(); + goForward?.(); }; - const resourceIcon = (resourceType: string, height: number) => { - let src = `https://raw.githubusercontent.com/kubernetes/community/refs/heads/main/icons/svg/resources/unlabeled/${resourceType}.svg` - return ( - - ) - } - - const renderResourceList = (resources, formkey, icon, title) => { - if (formData.runtime === "") { - return (<>) - } - if (resources.length <= 0) { - return <> - } - return ( -
-

{icon} {title}

-
    - {resources.map((resource) => ( -
  • - { - formData.resources = formData?.resources || {} - formData.resources[formkey] = formData.resources?.[formkey] || []; - if (formData.resources[formkey].includes(resource)) { - formData.resources[formkey] = formData.resources[formkey].filter( - (r) => r != resource, - ); - } else { - formData.resources?.[formkey].push(resource); - } - setFormData({...formData}) - }} - /> - {resource} -
  • - ))} -
-
- ) - } - return ( <>
-

- Novus Configuration -

+

Novus Configuration

Novus is our internal Kubernetes platform. This feature adds pre-built library panels scoped to your runtime. @@ -427,9 +397,7 @@ export function Component({ goBack, goForward, setDashboardPanels }) { onChange={(e) => setSelectedTeam(e.value)} dropdown /> -

- Name of the team owning the runtime. Defaults to your team -
+
Name of the team owning the runtime. Defaults to your team
@@ -442,44 +410,55 @@ export function Component({ goBack, goForward, setDashboardPanels }) { className={`form-input ${errors.runtime ? "error" : ""}`} dropdown /> - {errors.runtime && ( -
⚠️ {errors.runtime}
- )} -
- The Novus runtime name used to scope the dashboard panels. -
+ {errors.runtime &&
⚠️ {errors.runtime}
} +
The Novus runtime name used to scope the dashboard panels.
-
- {renderResourceList(nginxIngresses, "nginxHosts", resourceIcon("ing", 25), "Ingresses")} - {renderResourceList(deployments, "deployments", resourceIcon("deploy", 25), "Deployments")} - {renderResourceList(statefulsets, "statefulsets", resourceIcon("sts", 25), "Statefulsets")} + + {formData.runtime && ( + <> + } + resources={nginxIngresses} + selected={formData.resources.nginxHosts} + onToggle={(r) => toggleResource("nginxHosts", r)} + /> + } + resources={deployments} + selected={formData.resources.deployments} + onToggle={(r) => toggleResource("deployments", r)} + /> + } + resources={statefulsets} + selected={formData.resources.statefulsets} + onToggle={(r) => toggleResource("statefulsets", r)} + /> + + )}
- {goBack && ( - - )} - {goForward && ( - - )} + {goBack && } + {goForward && }
);