From cebd9edeb72d883646cc1376623e3dc34ceb1492 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Mon, 6 Jul 2026 11:50:01 +0200 Subject: [PATCH 1/4] inline resources links + mocks --- README.md | 5 + .../en/plugin__lightspeed-console-plugin.json | 13 + package.json | 1 + scripts/mock-ols-server.mjs | 1034 +++++++++++++++++ src/components/GeneralPage.tsx | 604 +++++----- src/components/InlineLinkedResource.tsx | 60 + src/components/LinkedResourceStatus.tsx | 71 ++ src/components/ResourceIcon.tsx | 5 +- src/components/linked-resources.css | 42 + src/consoleNavigation.ts | 19 + src/crStatus.ts | 214 ++++ src/hooks/useConsoleNavigation.ts | 23 + src/hooks/useLinkedResourceMarkdown.tsx | 90 ++ src/hooks/useMessageSources.tsx | 67 ++ src/linkedResourceStatusDisplay.ts | 138 +++ src/linkedResourceText.ts | 147 +++ src/linkedResourceWatch.ts | 76 ++ src/linkedResources.ts | 135 +++ src/pageContext.ts | 144 ++- src/resourceListParsing.ts | 255 ++++ src/resourceRefs.ts | 677 +++++++++++ src/resourceStatus.ts | 281 +++++ start-console.sh | 91 +- unit-tests/consoleNavigation.test.ts | 12 + unit-tests/crStatus.test.ts | 63 + unit-tests/fixtures/k8sModels.ts | 75 ++ .../linkedResourceStatusDisplay.test.ts | 108 ++ unit-tests/linkedResourceText.test.ts | 137 +++ unit-tests/linkedResourceWatch.test.ts | 93 ++ unit-tests/linkedResources.test.ts | 416 +++++++ unit-tests/pageContext.test.ts | 225 +++- unit-tests/resourceListParsing.test.ts | 101 ++ unit-tests/resourceRefs.test.ts | 455 ++++++++ unit-tests/resourceStatus.test.ts | 210 ++++ webpack.config.ts | 8 +- 35 files changed, 5771 insertions(+), 324 deletions(-) create mode 100755 scripts/mock-ols-server.mjs create mode 100644 src/components/InlineLinkedResource.tsx create mode 100644 src/components/LinkedResourceStatus.tsx create mode 100644 src/components/linked-resources.css create mode 100644 src/consoleNavigation.ts create mode 100644 src/crStatus.ts create mode 100644 src/hooks/useConsoleNavigation.ts create mode 100644 src/hooks/useLinkedResourceMarkdown.tsx create mode 100644 src/hooks/useMessageSources.tsx create mode 100644 src/linkedResourceStatusDisplay.ts create mode 100644 src/linkedResourceText.ts create mode 100644 src/linkedResourceWatch.ts create mode 100644 src/linkedResources.ts create mode 100644 src/resourceListParsing.ts create mode 100644 src/resourceRefs.ts create mode 100644 src/resourceStatus.ts create mode 100644 unit-tests/consoleNavigation.test.ts create mode 100644 unit-tests/crStatus.test.ts create mode 100644 unit-tests/fixtures/k8sModels.ts create mode 100644 unit-tests/linkedResourceStatusDisplay.test.ts create mode 100644 unit-tests/linkedResourceText.test.ts create mode 100644 unit-tests/linkedResourceWatch.test.ts create mode 100644 unit-tests/linkedResources.test.ts create mode 100644 unit-tests/resourceListParsing.test.ts create mode 100644 unit-tests/resourceRefs.test.ts create mode 100644 unit-tests/resourceStatus.test.ts diff --git a/README.md b/README.md index 9f2e8cef..d4e68f6b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ If your Lightspeed service is running locally on `http://localhost:8080`, the `start-console.sh` script includes a proxy configuration that routes requests through the console, avoiding CORS issues. +`start-console.sh` uses `quay.io/openshift/origin-console:4.22` by default to +match this plugin's SDK version. If the Lightspeed chat icon is missing, check +the browser console for plugin errors and try pinning the image explicitly: +`CONSOLE_TAG=4.22 npm run start-console`. + #### Running start-console with Apple silicon and podman If you are using podman on a Mac with Apple silicon, `npm run start-console` diff --git a/locales/en/plugin__lightspeed-console-plugin.json b/locales/en/plugin__lightspeed-console-plugin.json index f5e90018..6fbbb322 100644 --- a/locales/en/plugin__lightspeed-console-plugin.json +++ b/locales/en/plugin__lightspeed-console-plugin.json @@ -17,6 +17,8 @@ "Cancelled": "Cancelled", "Changes you made may not be saved.": "Changes you made may not be saved.", "Clear chat": "Clear chat", + "Cluster resource": "Cluster resource", + "Cluster resources": "Cluster resources", "Collapse": "Collapse", "Compressed in {{seconds}} seconds": "Compressed in {{seconds}} seconds", "Compressing history...": "Compressing history...", @@ -50,6 +52,7 @@ "Error querying OpenShift Lightspeed service": "Error querying OpenShift Lightspeed service", "Error submitting feedback": "Error submitting feedback", "Events": "Events", + "Evidence sources": "Evidence sources", "Expand": "Expand", "Expert guidance and clear answers": "Expert guidance and clear answers", "Explore deeper insights, engage in meaningful discussions, and unlock new possibilities with Red Hat OpenShift Lightspeed. Answers are provided by generative AI technology, please use appropriate caution when following recommendations.": "Explore deeper insights, engage in meaningful discussions, and unlock new possibilities with Red Hat OpenShift Lightspeed. Answers are provided by generative AI technology, please use appropriate caution when following recommendations.", @@ -79,6 +82,7 @@ "Large prompt": "Large prompt", "Leave": "Leave", "Loading MCP App...": "Loading MCP App...", + "Loading resource status": "Loading resource status", "Logs": "Logs", "MCP App Error": "MCP App Error", "MCP App: {{toolName}}": "MCP App: {{toolName}}", @@ -87,6 +91,8 @@ "Minimize": "Minimize", "Most recent {{lines}} lines": "Most recent {{lines}} lines", "Most recent {{numEvents}} events": "Most recent {{numEvents}} events", + "Next cluster resource": "Next cluster resource", + "Next source": "Next source", "No events": "No events", "No logs found": "No logs found", "No output returned": "No output returned", @@ -104,14 +110,21 @@ "Pod": "Pod", "Preview attachment": "Preview attachment", "Preview attachment - modified": "Preview attachment - modified", + "Previous cluster resource": "Previous cluster resource", + "Previous source": "Previous source", "Red Hat OpenShift Lightspeed": "Red Hat OpenShift Lightspeed", "Refresh": "Refresh", "Reject": "Reject", + "resource": "resource", + "resources": "resources", "Restore": "Restore", "Revert to original": "Revert to original", "Review required": "Review required", "Save": "Save", + "Showing {{shown}} of {{total}} cluster resources": "Showing {{shown}} of {{total}} cluster resources", "Silence": "Silence", + "source": "source", + "sources": "sources", "Status": "Status", "Stay": "Stay", "Structured content": "Structured content", diff --git a/package.json b/package.json index 38d4e096..00a17594 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build": "npm run clean && NODE_ENV=production npm run ts-node node_modules/.bin/webpack", "build-dev": "npm run clean && npm run ts-node node_modules/.bin/webpack", "start": "npm run ts-node node_modules/.bin/webpack serve", + "start-mock-ols": "node scripts/mock-ols-server.mjs", "start-console": "./start-console.sh", "i18n": "./i18n-scripts/build-i18n.sh && node ./i18n-scripts/set-english-defaults.js", "ts-node": "ts-node -O '{\"module\":\"commonjs\"}'", diff --git a/scripts/mock-ols-server.mjs b/scripts/mock-ols-server.mjs new file mode 100755 index 00000000..e9df2115 --- /dev/null +++ b/scripts/mock-ols-server.mjs @@ -0,0 +1,1034 @@ +#!/usr/bin/env node +/** + * Minimal mock OpenShift Lightspeed API for local linked-resources testing. + * + * Mock MCP tool output and SSE events align with kubernetes-mcp-server + * (list_output=table, projects_list YAML, nodes_top metrics) and + * lightspeed-service streaming (tool_result/end event shapes). + * + * Usage (three terminals): + * 1. npm run start-mock-ols + * 2. npm run start + * 3. oc login && npm run start-console + * + * No OpenShift cluster? Use a local kind cluster: + * kind create cluster + * npm run start-console # start-console.sh mints a dev token for plain Kubernetes + * + * Then open http://localhost:9000, open Lightspeed, and try e.g.: + * - "show pods" → pods in MOCK_NAMESPACE + * - "list projects" → projects_list + * - "list namespaces" → namespaces_list + * - "list configmaps" → resources_list (ConfigMap) + * - "list secrets" → resources_list (Secret) + * - "list services" → resources_list (Service) + * - "list deployments" → resources_list (Deployment) + * - "list statefulsets" → resources_list (StatefulSet) + * - "list routes" → resources_list (Route) + * - "show nodes" → nodes_top + * - "check deployment foo" → resources_get (single Deployment) + * + * Resource kinds mirror unit-tests/fixtures/k8sModels.ts (minus Pod/Namespace/Node + * which use dedicated scenarios). Each list kind supports MOCK_ env override. + * + * Environment: + * OLS_PORT Port (default: 8080) + * MOCK_OLS_SCENARIO Force scenario (see startup log for keys) + * MOCK_NAMESPACE Namespace for namespaced resources (default: default) + * MOCK_PODS Comma-separated pod names + * MOCK_POD_LIMIT Max pods from cluster (default: all; set e.g. 12 to cap) + * MOCK_DIVERSE_POD_STATUSES Set to 1 or true to cycle demo statuses in tool output (default: off) + * MOCK_LIST_LIMIT Max names for other list resources (default: 4) + * MOCK_DEPLOYMENT Deployment name for single-deployment scenario + * MOCK_DEPLOYMENTS Comma-separated deployment names (list scenario) + * MOCK_PROJECTS Comma-separated project names + * MOCK_NAMESPACES Comma-separated namespace names + * MOCK_NODES Comma-separated node names + * MOCK_ Comma-separated names per list resource (e.g. MOCK_SECRETS) + */ + +import { createServer } from 'node:http'; +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; + +const PORT = Number(process.env.OLS_PORT ?? 8080); +const MOCK_NAMESPACE = process.env.MOCK_NAMESPACE ?? 'default'; + +/** 0 or unset means no cap when fetching names from the cluster via oc. */ +const parseListLimit = (value, fallback) => { + if (value === undefined || value === '') { + return fallback; + } + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +}; + +const DEFAULT_LIST_LIMIT = parseListLimit(process.env.MOCK_LIST_LIMIT, 4); +const POD_LIST_LIMIT = parseListLimit(process.env.MOCK_POD_LIMIT, 0); +const MOCK_DIVERSE_POD_STATUSES = + process.env.MOCK_DIVERSE_POD_STATUSES === '1' || + process.env.MOCK_DIVERSE_POD_STATUSES === 'true'; + +/** Demo statuses for MCP table + assistant prose (icons still come from live cluster watch). */ +const DIVERSE_POD_STATUS_PRESETS = [ + { ready: '1/1', status: 'Running', restarts: '0' }, + { ready: '0/1', status: 'Pending', restarts: '0' }, + { ready: '0/1', status: 'ContainerCreating', restarts: '0' }, + { ready: '0/1', status: 'CrashLoopBackOff', restarts: '12' }, + { ready: '0/1', status: 'ImagePullBackOff', restarts: '0' }, + { ready: '0/1', status: 'Error', restarts: '3' }, + { ready: '0/1', status: 'Failed', restarts: '0' }, + { ready: '1/1', status: 'Succeeded', restarts: '0' }, +]; + +const PLACEHOLDER_POD_NAMES = [ + 'mock-pod-running', + 'mock-pod-pending', + 'mock-pod-crash', + 'mock-pod-imagepull', + 'mock-pod-error', + 'mock-pod-failed', +]; + +const splitEnvList = (value) => + value + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean) ?? []; + +const tryOc = (args) => { + try { + return execFileSync('oc', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + }).trim(); + } catch { + return null; + } +}; + +const tryOcJson = (args) => { + try { + return JSON.parse( + execFileSync('oc', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 10_000, + }), + ); + } catch { + return null; + } +}; + +const podReadyString = (pod) => { + const containers = pod.status?.containerStatuses ?? []; + if (containers.length === 0) { + return '0/1'; + } + const ready = containers.filter((container) => container.ready).length; + return `${ready}/${containers.length}`; +}; + +const podRestarts = (pod) => + (pod.status?.containerStatuses ?? []).reduce( + (sum, container) => sum + (container.restartCount ?? 0), + 0, + ); + +const podDisplayStatus = (pod) => { + const phase = pod.status?.phase ?? 'Unknown'; + for (const container of pod.status?.containerStatuses ?? []) { + const waitingReason = container.state?.waiting?.reason; + if (waitingReason) { + return waitingReason; + } + const terminatedReason = container.state?.terminated?.reason; + if (terminatedReason && phase !== 'Running' && phase !== 'Succeeded') { + return terminatedReason; + } + } + return phase; +}; + +const snapshotFromPod = (pod, index) => ({ + name: pod.metadata?.name ?? `pod-${index}`, + ready: podReadyString(pod), + status: podDisplayStatus(pod), + restarts: String(podRestarts(pod)), + age: '5m', + ip: pod.status?.podIP ?? '', + node: pod.spec?.nodeName ?? '', +}); + +const snapshotWithPreset = (name, index, useDiverseStatuses) => { + const preset = useDiverseStatuses + ? DIVERSE_POD_STATUS_PRESETS[index % DIVERSE_POD_STATUS_PRESETS.length] + : DIVERSE_POD_STATUS_PRESETS[0]; + return { + name, + ready: preset.ready, + status: preset.status, + restarts: preset.restarts, + age: '5m', + ip: `10.0.0.${index + 1}`, + node: 'worker-1', + }; +}; + +const resolvePodSnapshots = (namespace) => { + const fromEnv = splitEnvList(process.env.MOCK_PODS); + if (fromEnv.length > 0) { + return fromEnv.map((name, index) => + snapshotWithPreset(name, index, MOCK_DIVERSE_POD_STATUSES), + ); + } + + const list = tryOcJson(['get', 'pods', '-n', namespace, '-o', 'json']); + if (list?.items?.length > 0) { + const items = + POD_LIST_LIMIT > 0 ? list.items.slice(0, POD_LIST_LIMIT) : list.items; + const snapshots = items.map(snapshotFromPod); + if (MOCK_DIVERSE_POD_STATUSES) { + return snapshots.map((snapshot, index) => ({ + ...snapshot, + ...DIVERSE_POD_STATUS_PRESETS[index % DIVERSE_POD_STATUS_PRESETS.length], + name: snapshot.name, + ip: snapshot.ip, + node: snapshot.node, + })); + } + return snapshots; + } + + return PLACEHOLDER_POD_NAMES.map((name, index) => + snapshotWithPreset(name, index, MOCK_DIVERSE_POD_STATUSES), + ); +}; + +const resolveNames = ({ envVar, ocArgs, ocFallbackArgs, placeholders, limit = DEFAULT_LIST_LIMIT }) => { + const fromEnv = splitEnvList(process.env[envVar]); + if (fromEnv.length > 0) { + return fromEnv; + } + const fromCluster = tryOc(ocArgs) || (ocFallbackArgs ? tryOc(ocFallbackArgs) : null); + if (fromCluster) { + const allNames = fromCluster.split(/\s+/).filter(Boolean); + const names = limit > 0 ? allNames.slice(0, limit) : allNames; + if (names.length > 0) { + return names; + } + } + return placeholders; +}; + +const envVarForPlural = (plural) => `MOCK_${plural.replace(/-/g, '_').toUpperCase()}`; + +const listedResourceNames = (names) => names.join('\n'); + +const defaultListTokens = (kindLabel, { namespace, names, primary, namespaced }) => { + const count = names.length; + const plural = count === 1 ? kindLabel : `${kindLabel}s`; + const intro = namespaced + ? `I found ${count} ${plural} in the **${namespace}** namespace.\n\n` + : `I found ${count} ${plural} in this cluster.\n\n`; + return [ + intro, + names.map((name) => `**${name}**`).join('\n'), + `\n\n**${primary}** is a good place to start if you need more detail.`, + ]; +}; + +const workloadTable = + (apiVersion, kind) => + (namespace, names) => + names + .map((name) => `${namespace} ${apiVersion} ${kind} ${name} 1/1 1 1 5m app=mock`) + .join('\n'); + +const WORKLOAD_TABLE_HEADER = + 'NAMESPACE APIVERSION KIND NAME READY UP-TO-DATE AVAILABLE AGE LABELS'; + +const POD_TABLE_HEADER = + 'NAMESPACE APIVERSION KIND NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES LABELS'; + +/** + * List-resource definitions aligned with unit-tests/fixtures/k8sModels.ts. + * Pods, Namespaces, Nodes, and Projects use dedicated scenarios below. + */ +const LIST_RESOURCE_DEFS = [ + { + key: 'configmaps', + kind: 'ConfigMap', + plural: 'configmaps', + namespaced: true, + apiVersion: 'v1', + placeholders: ['kube-root-ca.crt', 'mock-app-config'], + detect: (q) => /\bconfigmaps?\b/.test(q) || /\bconfig maps?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME DATA AGE LABELS', + buildTable: (namespace, names) => + names + .map((name, index) => `${namespace} v1 ConfigMap ${name} ${index + 1} 5m app=mock`) + .join('\n'), + }, + { + key: 'secrets', + kind: 'Secret', + plural: 'secrets', + namespaced: true, + apiVersion: 'v1', + placeholders: ['builder-dockercfg-mock', 'mock-tls'], + detect: (q) => /\bsecrets?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME TYPE DATA AGE LABELS', + buildTable: (namespace, names) => + names.map((name) => `${namespace} v1 Secret ${name} Opaque 2 5m `).join('\n'), + }, + { + key: 'serviceaccounts', + kind: 'ServiceAccount', + plural: 'serviceaccounts', + namespaced: true, + apiVersion: 'v1', + placeholders: ['default', 'builder'], + detect: (q) => /\bserviceaccounts?\b/.test(q) || /\bservice accounts?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME SECRETS AGE LABELS', + buildTable: (namespace, names) => + names.map((name) => `${namespace} v1 ServiceAccount ${name} 1 5m `).join('\n'), + }, + { + key: 'services', + kind: 'Service', + plural: 'services', + namespaced: true, + apiVersion: 'v1', + placeholders: ['kubernetes', 'mock-api'], + detect: (q) => /\bservices?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR', + buildTable: (namespace, names) => + names + .map( + (name, index) => + `${namespace} v1 Service ${name} ClusterIP 10.96.${index + 1}.1 80/TCP 5m app=mock`, + ) + .join('\n'), + }, + { + key: 'deployments', + kind: 'Deployment', + plural: 'deployments', + namespaced: true, + apiVersion: 'apps/v1', + placeholders: ['mock-deployment-a', 'mock-deployment-b'], + detect: (q) => /\bdeployments\b/.test(q) || /\blist deployments?\b/.test(q), + tableHeader: WORKLOAD_TABLE_HEADER, + buildTable: workloadTable('apps/v1', 'Deployment'), + }, + { + key: 'statefulsets', + kind: 'StatefulSet', + plural: 'statefulsets', + namespaced: true, + apiVersion: 'apps/v1', + placeholders: ['mock-statefulset-a', 'mock-statefulset-b'], + detect: (q) => /\bstatefulsets?\b/.test(q) || /\bstateful sets?\b/.test(q), + tableHeader: WORKLOAD_TABLE_HEADER, + buildTable: workloadTable('apps/v1', 'StatefulSet'), + }, + { + key: 'daemonsets', + kind: 'DaemonSet', + plural: 'daemonsets', + namespaced: true, + apiVersion: 'apps/v1', + placeholders: ['mock-daemonset-a'], + detect: (q) => /\bdaemonsets?\b/.test(q) || /\bdaemon sets?\b/.test(q), + tableHeader: WORKLOAD_TABLE_HEADER, + buildTable: workloadTable('apps/v1', 'DaemonSet'), + }, + { + key: 'replicasets', + kind: 'ReplicaSet', + plural: 'replicasets', + namespaced: true, + apiVersion: 'apps/v1', + placeholders: ['mock-replicaset-a'], + detect: (q) => /\breplicasets?\b/.test(q) || /\breplica sets?\b/.test(q), + tableHeader: WORKLOAD_TABLE_HEADER, + buildTable: workloadTable('apps/v1', 'ReplicaSet'), + }, + { + key: 'replicationcontrollers', + kind: 'ReplicationController', + plural: 'replicationcontrollers', + namespaced: true, + apiVersion: 'v1', + placeholders: ['mock-rc-a'], + detect: (q) => + /\breplicationcontrollers?\b/.test(q) || /\breplication controllers?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME DESIRED CURRENT READY AGE SELECTOR LABELS', + buildTable: (namespace, names) => + names + .map((name) => `${namespace} v1 ReplicationController ${name} 1 1 1 5m app=mock `) + .join('\n'), + }, + { + key: 'deploymentconfigs', + kind: 'DeploymentConfig', + plural: 'deploymentconfigs', + namespaced: true, + apiVersion: 'apps.openshift.io/v1', + placeholders: ['mock-dc-a', 'mock-dc-b'], + detect: (q) => + /\bdeploymentconfigs?\b/.test(q) || /\bdeployment configs?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME REVISION DESIRED CURRENT TRIGGERED BY LABELS', + buildTable: (namespace, names) => + names + .map( + (name) => + `${namespace} apps.openshift.io/v1 DeploymentConfig ${name} 1 1 1 config `, + ) + .join('\n'), + }, + { + key: 'jobs', + kind: 'Job', + plural: 'jobs', + namespaced: true, + apiVersion: 'batch/v1', + placeholders: ['mock-job-a'], + detect: (q) => /\bjobs?\b/.test(q) && !/\bcronjobs?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME COMPLETIONS DURATION AGE LABELS', + buildTable: (namespace, names) => + names.map((name) => `${namespace} batch/v1 Job ${name} 1/1 12s 5m `).join('\n'), + }, + { + key: 'cronjobs', + kind: 'CronJob', + plural: 'cronjobs', + namespaced: true, + apiVersion: 'batch/v1', + placeholders: ['mock-cronjob-a'], + detect: (q) => /\bcronjobs?\b/.test(q) || /\bcron jobs?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE LABELS', + buildTable: (namespace, names) => + names + .map((name) => `${namespace} batch/v1 CronJob ${name} */5 * * * * False 0 5m `) + .join('\n'), + }, + { + key: 'ingresses', + kind: 'Ingress', + plural: 'ingresses', + namespaced: true, + apiVersion: 'networking.k8s.io/v1', + placeholders: ['mock-ingress'], + detect: (q) => /\bingresses?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME CLASS HOSTS ADDRESS PORTS AGE LABELS', + buildTable: (namespace, names) => + names + .map( + (name, index) => + `${namespace} networking.k8s.io/v1 Ingress ${name} openshift-default mock-${index + 1}.apps.example.com 10.0.0.1 80 5m `, + ) + .join('\n'), + }, + { + key: 'routes', + kind: 'Route', + plural: 'routes', + namespaced: true, + apiVersion: 'route.openshift.io/v1', + placeholders: ['mock-route'], + detect: (q) => /\broutes?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME AGE LABELS', + buildTable: (namespace, names) => + names + .map((name) => `${namespace} route.openshift.io/v1 Route ${name} 5m `) + .join('\n'), + }, + { + key: 'persistentvolumeclaims', + kind: 'PersistentVolumeClaim', + plural: 'persistentvolumeclaims', + namespaced: true, + apiVersion: 'v1', + placeholders: ['mock-pvc-a'], + detect: (q) => + /\bpersistentvolumeclaims?\b/.test(q) || + /\bpersistent volume claims?\b/.test(q) || + /\bpvcs?\b/.test(q), + tableHeader: + 'NAMESPACE APIVERSION KIND NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE LABELS', + buildTable: (namespace, names) => + names + .map( + (name) => + `${namespace} v1 PersistentVolumeClaim ${name} Bound pv-mock 1Gi RWO mock-sc 5m `, + ) + .join('\n'), + }, + { + key: 'persistentvolumes', + kind: 'PersistentVolume', + plural: 'persistentvolumes', + namespaced: false, + apiVersion: 'v1', + placeholders: ['pv-mock-a'], + detect: (q) => + /\bpersistentvolumes?\b/.test(q) || + /\bpersistent volumes?\b/.test(q) || + (/\bpvs?\b/.test(q) && !/\bpvcs?\b/.test(q)), + tableHeader: 'APIVERSION KIND NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS', + buildTable: (_namespace, names) => + names + .map((name) => `v1 PersistentVolume ${name} 1Gi RWO Delete Bound default/${name} mock-sc`) + .join('\n'), + }, + { + key: 'horizontalpodautoscalers', + kind: 'HorizontalPodAutoscaler', + plural: 'horizontalpodautoscalers', + namespaced: true, + apiVersion: 'autoscaling/v2', + placeholders: ['mock-hpa'], + detect: (q) => + /\bhorizontalpodautoscalers?\b/.test(q) || + /\bhorizontal pod autoscalers?\b/.test(q) || + /\bhpas?\b/.test(q), + tableHeader: + 'NAMESPACE APIVERSION KIND NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE LABELS', + buildTable: (namespace, names) => + names + .map( + (name) => + `${namespace} autoscaling/v2 HorizontalPodAutoscaler ${name} Deployment/mock-deployment 50%/60% 1 5 2 5m `, + ) + .join('\n'), + }, + { + key: 'subscriptions', + kind: 'Subscription', + plural: 'subscriptions', + namespaced: true, + apiVersion: 'operators.coreos.com/v1alpha1', + placeholders: ['openshift-operators-redhat'], + detect: (q) => /\bsubscriptions?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME PACKAGE SOURCE CHANNEL LABELS', + buildTable: (namespace, names) => + names + .map( + (name) => + `${namespace} operators.coreos.com/v1alpha1 Subscription ${name} netobserv-operator redhat-operators stable `, + ) + .join('\n'), + }, + { + key: 'virtualmachines', + kind: 'VirtualMachine', + plural: 'virtualmachines', + namespaced: true, + apiVersion: 'kubevirt.io/v1', + placeholders: ['mock-vm-a'], + detect: (q) => + /\bvirtualmachines?\b/.test(q) || + /\bvirtual machines?\b/.test(q) || + (/\bvms?\b/.test(q) && !/\bhpas?\b/.test(q)), + tableHeader: 'NAMESPACE APIVERSION KIND NAME AGE STATUS READY LABELS', + buildTable: (namespace, names) => + names + .map((name) => `${namespace} kubevirt.io/v1 VirtualMachine ${name} 5m Running True `) + .join('\n'), + }, + { + key: 'flowcollectors', + kind: 'FlowCollector', + plural: 'flowcollectors', + namespaced: false, + apiVersion: 'flows.netobserv.io/v1beta2', + placeholders: ['cluster'], + detect: (q) => /\bflowcollectors?\b/.test(q) || /\bflow collectors?\b/.test(q), + tableHeader: 'APIVERSION KIND NAME STATUS AGE', + buildTable: (_namespace, names) => + names.map((name) => `flows.netobserv.io/v1beta2 FlowCollector ${name} Ready 5m`).join('\n'), + }, + { + key: 'flowcollectorslices', + kind: 'FlowCollectorSlice', + plural: 'flowcollectorslices', + namespaced: true, + apiVersion: 'flows.netobserv.io/v1beta2', + placeholders: ['workers'], + detect: (q) => /\bflowcollectorslices?\b/.test(q) || /\bflow collector slices?\b/.test(q), + tableHeader: 'NAMESPACE APIVERSION KIND NAME STATUS AGE LABELS', + buildTable: (namespace, names) => + names + .map( + (name) => + `${namespace} flows.netobserv.io/v1beta2 FlowCollectorSlice ${name} Ready 5m `, + ) + .join('\n'), + }, + { + key: 'clusterversions', + kind: 'ClusterVersion', + plural: 'clusterversions', + namespaced: false, + apiVersion: 'config.openshift.io/v1', + placeholders: ['version'], + detect: (q) => /\bclusterversions?\b/.test(q) || /\bcluster versions?\b/.test(q), + tableHeader: 'APIVERSION KIND NAME VERSION AVAILABLE PROGRESSING DEGRADED SINCE', + buildTable: (_namespace, names) => + names + .map((name) => `config.openshift.io/v1 ClusterVersion ${name} 4.16.0 True False False 5m`) + .join('\n'), + }, + { + key: 'machineconfigpools', + kind: 'MachineConfigPool', + plural: 'machineconfigpools', + namespaced: false, + apiVersion: 'machineconfiguration.openshift.io/v1', + placeholders: ['worker', 'master'], + detect: (q) => + /\bmachineconfigpools?\b/.test(q) || /\bmachine config pools?\b/.test(q), + tableHeader: 'APIVERSION KIND NAME CONFIG UPDATED UPDATING DEGRADED MACHINECOUNT', + buildTable: (_namespace, names) => + names + .map( + (name) => + `machineconfiguration.openshift.io/v1 MachineConfigPool ${name} rendered-${name} True False False 3`, + ) + .join('\n'), + }, + { + key: 'ingresscontrollers', + kind: 'IngressController', + plural: 'ingresscontrollers', + namespaced: false, + apiVersion: 'operator.openshift.io/v1', + placeholders: ['default'], + detect: (q) => + /\bingresscontrollers?\b/.test(q) || /\bingress controllers?\b/.test(q), + tableHeader: 'APIVERSION KIND NAME DOMAIN AVAILABLE PROGRESSING DEGRADED', + buildTable: (_namespace, names) => + names + .map( + (name) => + `operator.openshift.io/v1 IngressController ${name} apps.example.com True False False`, + ) + .join('\n'), + }, +]; + +const listResourceRuntime = LIST_RESOURCE_DEFS.map((def) => { + const envVar = envVarForPlural(def.plural); + const ocArgs = def.namespaced + ? ['get', def.plural, '-n', MOCK_NAMESPACE, '-o', 'jsonpath={.items[*].metadata.name}'] + : ['get', def.plural, '-o', 'jsonpath={.items[*].metadata.name}']; + + const names = resolveNames({ + envVar, + ocArgs, + placeholders: def.placeholders, + limit: def.limit ?? DEFAULT_LIST_LIMIT, + }); + + const scenario = () => { + const namespace = MOCK_NAMESPACE; + const primary = names[0]; + const toolArgs = def.namespaced + ? { apiVersion: def.apiVersion, kind: def.kind, namespace } + : { apiVersion: def.apiVersion, kind: def.kind }; + const formatTokens = def.formatTokens ?? defaultListTokens; + return { + toolName: 'resources_list', + toolArgs, + toolContent: `${def.tableHeader}\n${def.buildTable(def.namespaced ? namespace : undefined, names)}`, + tokens: formatTokens(def.kind, { + namespace, + names, + primary, + namespaced: def.namespaced, + }), + }; + }; + + return { ...def, envVar, names, scenario, rule: { key: def.key, test: def.detect } }; +}); + +const POD_SNAPSHOTS = resolvePodSnapshots(MOCK_NAMESPACE); +const POD_NAMES = POD_SNAPSHOTS.map((pod) => pod.name); + +const DEPLOYMENT_NAME = (() => { + if (process.env.MOCK_DEPLOYMENT) { + return process.env.MOCK_DEPLOYMENT; + } + return ( + tryOc(['get', 'deployments', '-n', MOCK_NAMESPACE, '-o', 'jsonpath={.items[0].metadata.name}']) || + 'mock-deployment' + ); +})(); + +const PROJECT_NAMES = resolveNames({ + envVar: 'MOCK_PROJECTS', + ocArgs: ['get', 'projects', '-o', 'jsonpath={.items[*].metadata.name}'], + ocFallbackArgs: ['get', 'namespaces', '-o', 'jsonpath={.items[*].metadata.name}'], + placeholders: ['default', 'kube-system'], + limit: DEFAULT_LIST_LIMIT, +}); + +const NAMESPACE_NAMES = resolveNames({ + envVar: 'MOCK_NAMESPACES', + ocArgs: ['get', 'namespaces', '-o', 'jsonpath={.items[*].metadata.name}'], + placeholders: ['default', 'kube-system', 'kube-public'], + limit: DEFAULT_LIST_LIMIT, +}); + +const NODE_NAMES = resolveNames({ + envVar: 'MOCK_NODES', + ocArgs: ['get', 'nodes', '-o', 'jsonpath={.items[*].metadata.name}'], + placeholders: ['worker-1.example.internal', 'worker-2.example.internal'], + limit: DEFAULT_LIST_LIMIT, +}); + +const sseLine = (event, data) => `data: ${JSON.stringify({ event, data })}\n\n`; + +const buildPodsTable = (namespace, pods) => + pods + .map( + (pod) => + `${namespace} v1 Pod ${pod.name} ${pod.ready} ${pod.status} ${pod.restarts} ${pod.age} ${pod.ip} ${pod.node} app=mock`, + ) + .join('\n'); + +const buildProjectsYaml = (projects) => + projects + .map( + (name) => `- apiVersion: project.openshift.io/v1 + kind: Project + metadata: + name: ${name} + status: + phase: Active`, + ) + .join('\n'); + +const buildDeploymentYaml = (namespace, name) => `apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${name} + namespace: ${namespace} + labels: + app: mock +spec: + replicas: 1 + selector: + matchLabels: + app: mock + template: + metadata: + labels: + app: mock + spec: + containers: + - name: mock + image: registry/mock/${name}:latest +status: + replicas: 1 + readyReplicas: 1 + availableReplicas: 1`; + +const buildNamespacesTable = (namespaces) => + namespaces + .map((name) => `v1 Namespace ${name} Active 3h38m kubernetes.io/metadata.name=${name}`) + .join('\n'); + +const buildNodesTopTable = (nodeNames) => + [ + 'NAME CPU(cores) MEMORY(bytes)', + ...nodeNames.map((name, index) => `${name} ${100 + index * 10}m ${3000 + index * 100}Mi`), + ].join('\n'); + +/** Markdown hard line break (two trailing spaces before newline). */ +const mdLine = (line) => `${line} `; + +const labeledPodBlock = (pod) => + [ + mdLine(`Name: ${pod.name}`), + mdLine(`Ready: ${pod.ready}`), + mdLine(`Status: ${pod.status}`), + mdLine(`Restarts: ${pod.restarts}`), + mdLine(`Age: ${pod.age}`), + ].join('\n'); + +const verticalPodListing = (pods) => pods.map(labeledPodBlock).join('\n\n'); + +const labeledNodeBlock = (name, index) => + [ + mdLine(`Name: ${name}`), + mdLine(`CPU: ${100 + index * 10}m`), + mdLine(`Memory: ${3000 + index * 100}Mi`), + ].join('\n'); + +const verticalNodeListing = (nodeNames) => nodeNames.map(labeledNodeBlock).join('\n\n'); + +const scenarios = Object.fromEntries( + listResourceRuntime.map(({ key, scenario }) => [key, scenario]), +); + +Object.assign(scenarios, { + pods: () => { + const namespace = MOCK_NAMESPACE; + const primaryPod = POD_NAMES[0]; + const count = POD_NAMES.length; + return { + toolName: 'pods_list_in_namespace', + toolArgs: { namespace }, + toolContent: `${POD_TABLE_HEADER}\n${buildPodsTable(namespace, POD_SNAPSHOTS)}`, + tokens: [ + `I found ${count} pod${count === 1 ? '' : 's'} in the **${namespace}** namespace.\n\n`, + verticalPodListing(POD_SNAPSHOTS), + `\n\nPod **${primaryPod}** is a good place to start troubleshooting.`, + ], + }; + }, + projects: () => { + const primary = PROJECT_NAMES[0]; + const count = PROJECT_NAMES.length; + return { + toolName: 'projects_list', + toolArgs: {}, + toolContent: buildProjectsYaml(PROJECT_NAMES), + tokens: [ + `I found ${count} project${count === 1 ? '' : 's'} on this cluster.\n\n`, + PROJECT_NAMES.map((name) => `**${name}**`).join('\n'), + `\n\nProject **${primary}** is a common starting point.`, + ], + }; + }, + deployment: () => { + const namespace = MOCK_NAMESPACE; + const name = DEPLOYMENT_NAME; + return { + toolName: 'resources_get', + toolArgs: { apiVersion: 'apps/v1', kind: 'Deployment', name, namespace }, + toolContent: buildDeploymentYaml(namespace, name), + tokens: [ + `Deployment **${name}** in the **${namespace}** namespace may need attention.\n\n`, + `Open **${name}** in the console to inspect replica status and rollout history.`, + ], + }; + }, + namespaces: () => { + const primary = NAMESPACE_NAMES[0]; + const count = NAMESPACE_NAMES.length; + return { + toolName: 'namespaces_list', + toolArgs: {}, + toolContent: `APIVERSION KIND NAME STATUS AGE LABELS\n${buildNamespacesTable(NAMESPACE_NAMES)}`, + tokens: [ + `I found ${count} namespace${count === 1 ? '' : 's'} in this cluster.\n\n`, + listedResourceNames(NAMESPACE_NAMES), + `\n\nNamespace **${primary}** is often used for application workloads.`, + ], + }; + }, + nodes: () => { + const primary = NODE_NAMES[0]; + const count = NODE_NAMES.length; + return { + toolName: 'nodes_top', + toolArgs: {}, + toolContent: buildNodesTopTable(NODE_NAMES), + tokens: [ + `Here are ${count} node${count === 1 ? '' : 's'} with current CPU and memory usage:\n\n`, + verticalNodeListing(NODE_NAMES), + `\n\nNode **${primary}** has the most relevant metrics right now.`, + ], + }; + }, +}); + +const SCENARIO_HINTS = Object.keys(scenarios).join(' | '); + +/** Strip console page context prepended by the plugin (`Context: …\n\n`). */ +const extractUserQuery = (query) => { + const normalized = (query ?? '').trim(); + const match = normalized.match(/^context:\s*[\s\S]*?\n\n([\s\S]*)$/i); + return (match?.[1] ?? normalized).trim(); +}; + +const SCENARIO_RULES = [ + ...listResourceRuntime.map(({ rule }) => rule), + { + key: 'namespaces', + test: (q) => /\bnamespaces?\b/.test(q) && !/\bpods?\b/.test(q), + }, + { + key: 'nodes', + test: (q) => /\bnodes?\b/.test(q), + }, + { + key: 'projects', + test: (q) => /\bprojects?\b/.test(q), + }, + { + key: 'deployment', + test: (q) => /\bdeployment\b/.test(q) || /\bdeploy\b/.test(q), + }, + { + key: 'pods', + test: (q) => /\bpods?\b/.test(q), + }, +]; + +const detectScenario = (rawQuery) => { + const forced = process.env.MOCK_OLS_SCENARIO?.toLowerCase(); + if (forced && scenarios[forced]) { + return forced; + } + + const userQuery = extractUserQuery(rawQuery).toLowerCase(); + const matched = SCENARIO_RULES.find((rule) => rule.test(userQuery)); + return matched?.key ?? 'pods'; +}; + +const buildStreamBody = (query) => { + const scenarioKey = detectScenario(query); + const scenario = scenarios[scenarioKey](); + const conversationId = randomUUID(); + const toolId = `tool-${scenarioKey}-1`; + const lines = [ + sseLine('start', { conversation_id: conversationId }), + sseLine('tool_call', { id: toolId, name: scenario.toolName, args: scenario.toolArgs }), + sseLine('tool_result', { + id: toolId, + name: scenario.toolName, + type: 'tool_result', + round: 1, + content: scenario.toolContent, + status: 'success', + }), + ]; + + scenario.tokens.forEach((token, index) => { + lines.push(sseLine('token', { id: index, token })); + }); + + lines.push( + sseLine('end', { + referenced_documents: [], + truncated: false, + input_tokens: 120, + output_tokens: 80, + reasoning_tokens: 0, + }), + ); + return { body: lines.join(''), scenarioKey, conversationId }; +}; + +const readBody = (req) => + new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); + +const json = (res, status, payload) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +}; + +const server = createServer(async (req, res) => { + const { method, url } = req; + + if (method === 'GET' && url === '/readiness') { + return json(res, 200, { ready: true }); + } + + if (method === 'POST' && url === '/authorized') { + return json(res, 200, { user_id: 'mock-user', username: 'mock-user' }); + } + + if (method === 'GET' && url === '/v1/feedback/status') { + return json(res, 200, { status: { enabled: true } }); + } + + if (method === 'POST' && url === '/v1/feedback') { + return json(res, 200, { message: 'Feedback received' }); + } + + if (method === 'POST' && url === '/v1/streaming_query') { + let query = ''; + try { + const raw = await readBody(req); + const body = raw ? JSON.parse(raw) : {}; + query = body.query ?? ''; + } catch { + // ignore parse errors; still return a mock stream + } + + const { body, scenarioKey, conversationId } = buildStreamBody(query); + // eslint-disable-next-line no-console + console.log(`[mock-ols] streaming_query scenario=${scenarioKey} query=${JSON.stringify(query)}`); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.end(body); + return; + } + + // eslint-disable-next-line no-console + console.log(`[mock-ols] ${method} ${url} → 404`); + json(res, 404, { detail: `Not found: ${method} ${url}` }); +}); + +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Mock OLS listening on http://localhost:${PORT}`); + // eslint-disable-next-line no-console + console.log(` namespace: ${MOCK_NAMESPACE}`); + // eslint-disable-next-line no-console + console.log(` scenarios: ${SCENARIO_HINTS}`); + // eslint-disable-next-line no-console + console.log(` pods: ${POD_SNAPSHOTS.map((pod) => `${pod.name}(${pod.status})`).join(', ')}`); + if (MOCK_DIVERSE_POD_STATUSES) { + // eslint-disable-next-line no-console + console.log(' note: MOCK_DIVERSE_POD_STATUSES overrides tool-output status (icons use live watch)'); + } + // eslint-disable-next-line no-console + console.log(` projects: ${PROJECT_NAMES.join(', ')}`); + // eslint-disable-next-line no-console + console.log(` namespaces: ${NAMESPACE_NAMES.join(', ')}`); + // eslint-disable-next-line no-console + console.log(` nodes: ${NODE_NAMES.join(', ')}`); + for (const { key, names } of listResourceRuntime) { + // eslint-disable-next-line no-console + console.log(` ${key.padEnd(13)} ${names.join(', ')}`); + } + // eslint-disable-next-line no-console + console.log(''); + // eslint-disable-next-line no-console + console.log('Try in Lightspeed: "list secrets", "list routes", "list statefulsets", …'); + // eslint-disable-next-line no-console + console.log('Force a scenario: MOCK_OLS_SCENARIO=routes npm run start-mock-ols'); + // eslint-disable-next-line no-console + console.log(''); + // eslint-disable-next-line no-console + console.log('Next: npm run start (terminal 2)'); + // eslint-disable-next-line no-console + console.log(''); + // eslint-disable-next-line no-console + console.log('Then: oc login && npm run start-console (terminal 3)'); + // eslint-disable-next-line no-console + console.log(' or: kind create cluster && npm run start-console (no OpenShift needed)'); + // eslint-disable-next-line no-console + console.log(''); + // eslint-disable-next-line no-console + console.log('Finally: open http://localhost:9000'); +}); diff --git a/src/components/GeneralPage.tsx b/src/components/GeneralPage.tsx index 62e9ae1d..595d0964 100644 --- a/src/components/GeneralPage.tsx +++ b/src/components/GeneralPage.tsx @@ -3,7 +3,7 @@ import { defer } from 'lodash'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk'; +import { consoleFetchJSON, useK8sModels } from '@openshift-console/dynamic-plugin-sdk'; import { Alert, Badge, @@ -41,7 +41,10 @@ import { toOLSAttachment } from '../attachments'; import { getApiUrl } from '../config'; import { copyToClipboard } from '../clipboard'; import { ErrorType, getFetchErrorMessage } from '../error'; +import { K8sModelRef } from '../pageContext'; import { AuthStatus, getRequestInitWithAuthHeader, useAuth } from '../hooks/useAuth'; +import { useLinkedResourceMarkdown } from '../hooks/useLinkedResourceMarkdown'; +import { useMessageSources } from '../hooks/useMessageSources'; import { useBoolean } from '../hooks/useBoolean'; import { useFirstTimeUser } from '../hooks/useFirstTimeUser'; import { useIsDarkTheme } from '../hooks/useIsDarkTheme'; @@ -55,7 +58,7 @@ import { userFeedbackSetText, } from '../redux-actions'; import { State } from '../redux-reducers'; -import { Attachment, ChatEntry, ReferencedDoc, Tool } from '../types'; +import { Attachment, ChatEntry, Tool } from '../types'; import AttachmentLabel from './AttachmentLabel'; import AttachmentsSizeAlert from './AttachmentsSizeAlert'; import ImportAction from './ImportAction'; @@ -84,15 +87,6 @@ const ExternalLink: React.FC = ({ children, href }) => ( ); -const isURL = (s: string): boolean => { - try { - const url = new URL(s); - return !!(url.protocol && url.host); - } catch { - return false; - } -}; - const ImportCodeBlockAction: React.FC = () => { const containerRef = React.useRef(null); const [value, setValue] = React.useState(''); @@ -124,307 +118,324 @@ const THUMBS_UP = 1; type ChatHistoryEntryProps = { conversationID: string; entryIndex: number; + k8sModels: Record; + modelsLoaded: boolean; }; -const ChatHistoryEntry = React.memo(({ conversationID, entryIndex }: ChatHistoryEntryProps) => { - const { t } = useTranslation('plugin__lightspeed-console-plugin'); +const ChatHistoryEntry = React.memo( + ({ conversationID, entryIndex, k8sModels, modelsLoaded }: ChatHistoryEntryProps) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); - const dispatch = useDispatch(); + const dispatch = useDispatch(); - const [feedbackError, setFeedbackError] = React.useState(); - const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false); - const [isContextExpanded, toggleContextExpanded] = useBoolean(false); + const [feedbackError, setFeedbackError] = React.useState(); + const [feedbackSubmitted, setFeedbackSubmitted] = React.useState(false); + const [isContextExpanded, toggleContextExpanded] = useBoolean(false); - const entryMap = useSelector((s: State) => s.plugins?.ols?.getIn(['chatHistory', entryIndex])); - const entry = entryMap.toJS() as ChatEntry; + const entryMap = useSelector((s: State) => s.plugins?.ols?.getIn(['chatHistory', entryIndex])); + const entry = entryMap?.toJS() as ChatEntry; + const toolsMap = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'tools']), + ) as ImmutableMap> | undefined; - const attachments: ImmutableMap = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex - 1, 'attachments']), - ); - const isFeedbackOpen: boolean = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'isOpen']), - ); - const query: string = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex - 1, 'text']), - ); - const response: string = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'text']), - ); + const aiTools = React.useMemo((): Record | undefined => { + if (!toolsMap || toolsMap.size === 0) { + return undefined; + } + return toolsMap.toJS() as Record; + }, [toolsMap]); - const isUserFeedbackEnabled = useSelector((s: State) => - s.plugins?.ols?.get('isUserFeedbackEnabled'), - ); - const sentiment: number = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'sentiment']), - ); - const feedbackText: string = useSelector((s: State) => - s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'text']), - ); + const attachments: ImmutableMap = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex - 1, 'attachments']), + ); + const isFeedbackOpen: boolean = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'isOpen']), + ); + const query: string = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex - 1, 'text']), + ); + const response: string = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'text']), + ); - const [isDarkTheme] = useIsDarkTheme(); - - const onThumbsDown = React.useCallback(() => { - dispatch(userFeedbackOpen(entryIndex)); - dispatch(userFeedbackSetSentiment(entryIndex, THUMBS_DOWN)); - setFeedbackSubmitted(false); - }, [dispatch, entryIndex]); - - const onThumbsUp = React.useCallback(() => { - dispatch(userFeedbackOpen(entryIndex)); - dispatch(userFeedbackSetSentiment(entryIndex, THUMBS_UP)); - setFeedbackSubmitted(false); - }, [dispatch, entryIndex]); - - const onFeedbackClose = React.useCallback(() => { - dispatch(userFeedbackClose(entryIndex)); - }, [dispatch, entryIndex]); - - const onFeedbackTextChange = React.useCallback( - (_event: React.ChangeEvent, value: string) => { - dispatch(userFeedbackSetText(entryIndex, value)); - }, - [dispatch, entryIndex], - ); + const isUserFeedbackEnabled = useSelector((s: State) => + s.plugins?.ols?.get('isUserFeedbackEnabled'), + ); + const sentiment: number = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'sentiment']), + ); + const feedbackText: string = useSelector((s: State) => + s.plugins?.ols?.getIn(['chatHistory', entryIndex, 'userFeedback', 'text']), + ); - const onFeedbackSubmit = React.useCallback(() => { - const userQuestion = attachments - ? `${query}\n---\nThe attachments that were sent with the prompt are shown below.\n${JSON.stringify(attachments.valueSeq().map(toOLSAttachment), null, 2)}` - : query; - - /* eslint-disable camelcase */ - const requestJSON = { - conversation_id: conversationID, - llm_response: response, - sentiment, - user_feedback: feedbackText ?? '', - user_question: userQuestion, - }; - /* eslint-enable camelcase */ - - consoleFetchJSON - .post(USER_FEEDBACK_ENDPOINT, requestJSON, getRequestInitWithAuthHeader(), REQUEST_TIMEOUT) - .then(() => { - setFeedbackSubmitted(true); - }) - .catch((err) => { - setFeedbackError(getFetchErrorMessage(err, t)); - setFeedbackSubmitted(false); - }); - }, [conversationID, query, attachments, response, sentiment, feedbackText, t]); + const [isDarkTheme] = useIsDarkTheme(); - if (entry.who === 'user' && entry.hidden) { - return null; - } + const isAiEntry = entry?.who === 'ai'; + const messageSources = useMessageSources({ + enabled: isAiEntry && !entry.isStreaming && !entry.error && modelsLoaded, + references: isAiEntry ? entry.references : undefined, + }); + const linkedResourceMarkdown = useLinkedResourceMarkdown({ + enabled: isAiEntry && !entry.isStreaming && !entry.error && modelsLoaded, + k8sModels, + responseText: isAiEntry ? entry.text : undefined, + tools: aiTools, + }); + const displayContent = isAiEntry ? linkedResourceMarkdown.content : entry?.text; + + const onThumbsDown = React.useCallback(() => { + dispatch(userFeedbackOpen(entryIndex)); + dispatch(userFeedbackSetSentiment(entryIndex, THUMBS_DOWN)); + setFeedbackSubmitted(false); + }, [dispatch, entryIndex]); + + const onThumbsUp = React.useCallback(() => { + dispatch(userFeedbackOpen(entryIndex)); + dispatch(userFeedbackSetSentiment(entryIndex, THUMBS_UP)); + setFeedbackSubmitted(false); + }, [dispatch, entryIndex]); + + const onFeedbackClose = React.useCallback(() => { + dispatch(userFeedbackClose(entryIndex)); + }, [dispatch, entryIndex]); + + const onFeedbackTextChange = React.useCallback( + (_event: React.ChangeEvent, value: string) => { + dispatch(userFeedbackSetText(entryIndex, value)); + }, + [dispatch, entryIndex], + ); - if (entry.who === 'ai') { - const thumbsUpTooltip = t('Good response'); - const thumbsDownTooltip = t('Bad response'); - const actions = entry.error - ? undefined - : { - copy: { onClick: () => copyToClipboard(entry.text) }, - ...(isUserFeedbackEnabled && { - positive: { - clickedTooltipContent: thumbsUpTooltip, - onClick: onThumbsUp, - tooltipContent: thumbsUpTooltip, - }, - negative: { - clickedTooltipContent: thumbsDownTooltip, - onClick: onThumbsDown, - tooltipContent: thumbsDownTooltip, - }, - }), - }; - - let sources: SourcesCardProps | undefined; - if (Array.isArray(entry.references)) { - const references: ReferencedDoc[] = entry.references.filter( - (r) => - r && typeof r.doc_title === 'string' && typeof r.doc_url === 'string' && isURL(r.doc_url), - ); - if (references.length > 0) { - sources = { - sources: references.map((r) => ({ - isExternal: true, - title: r.doc_title, - link: r.doc_url, - })), - }; - } + const onFeedbackSubmit = React.useCallback(() => { + const userQuestion = attachments + ? `${query}\n---\nThe attachments that were sent with the prompt are shown below.\n${JSON.stringify(attachments.valueSeq().map(toOLSAttachment), null, 2)}` + : query; + + /* eslint-disable camelcase */ + const requestJSON = { + conversation_id: conversationID, + llm_response: response, + sentiment, + user_feedback: feedbackText ?? '', + user_question: userQuestion, + }; + /* eslint-enable camelcase */ + + consoleFetchJSON + .post(USER_FEEDBACK_ENDPOINT, requestJSON, getRequestInitWithAuthHeader(), REQUEST_TIMEOUT) + .then(() => { + setFeedbackSubmitted(true); + }) + .catch((err) => { + setFeedbackError(getFetchErrorMessage(err, t)); + setFeedbackSubmitted(false); + }); + }, [conversationID, query, attachments, response, sentiment, feedbackText, t]); + + if (!entry) { + return null; } - const pendingApprovalTools = entry.tools - ? Object.entries(entry.tools as unknown as { [key: string]: Tool }).filter( - ([, tool]) => tool.isUserApproval && !tool.isApproved && !tool.isDenied, - ) - : []; - - const historyCompressedAlert = ( - } - isInline - isPlain - title={t('History compressed')} - variant="success" - /> - ); + if (entry.who === 'user' && entry.hidden) { + return null; + } - return ( - , - isExpandable: true, - }} - content={entry.text} - data-test="ols-plugin__chat-entry-ai" - extraContent={{ - afterMainContent: ( - <> - {entry.error && ( - - {entry.error.moreInfo ? entry.error.moreInfo : entry.error.message} - - )} - {entry.historyCompression?.status === 'compressing' && !entry.isCancelled && ( - } - isInline - isPlain - title={t('Compressing history...')} - variant="info" - /> - )} - {entry.historyCompression?.status === 'done' && - (entry.historyCompression.durationMs === undefined ? ( - historyCompressedAlert - ) : ( - copyToClipboard(entry.text) }, + ...(isUserFeedbackEnabled && { + positive: { + clickedTooltipContent: thumbsUpTooltip, + onClick: onThumbsUp, + tooltipContent: thumbsUpTooltip, + }, + negative: { + clickedTooltipContent: thumbsDownTooltip, + onClick: onThumbsDown, + tooltipContent: thumbsDownTooltip, + }, + }), + }; + + const sources: SourcesCardProps | undefined = messageSources.messageSources; + + const pendingApprovalTools = aiTools + ? Object.entries(aiTools).filter( + ([, tool]) => tool.isUserApproval && !tool.isApproved && !tool.isDenied, + ) + : []; + + const historyCompressedAlert = ( + } + isInline + isPlain + title={t('History compressed')} + variant="success" + /> + ); + + return ( + , + isExpandable: true, + }} + content={displayContent} + data-test="ols-plugin__chat-entry-ai" + extraContent={{ + afterMainContent: ( + <> + {entry.error && ( + - {historyCompressedAlert} - + {entry.error.moreInfo ? entry.error.moreInfo : entry.error.message} + + )} + {entry.historyCompression?.status === 'compressing' && !entry.isCancelled && ( + } + isInline + isPlain + title={t('Compressing history...')} + variant="info" + /> + )} + {entry.historyCompression?.status === 'done' && + (entry.historyCompression.durationMs === undefined ? ( + historyCompressedAlert + ) : ( + + {historyCompressedAlert} + + ))} + {entry.isTruncated && ( + + {t('Conversation history has been truncated to fit within context window.')} + + )} + {entry.isCancelled && ( + + )} + {pendingApprovalTools.map(([toolID, tool]) => ( + ))} - {entry.isTruncated && ( - - {t('Conversation history has been truncated to fit within context window.')} - - )} - {entry.isCancelled && ( - - )} - {pendingApprovalTools.map(([toolID, tool]) => ( - - ))} - {entry.tools && } - - ), - endContent: feedbackError ? ( - - {feedbackError.moreInfo ? feedbackError.moreInfo : feedbackError.message} - - ) : undefined, - }} - hasRoundAvatar={false} - isCompact - isLoading={!entry.text && !entry.isCancelled && !entry.error} - name="OpenShift Lightspeed" - role="bot" - sources={sources} - timestamp=" " - userFeedbackComplete={ - isFeedbackOpen && feedbackSubmitted ? { onClose: onFeedbackClose } : undefined - } - userFeedbackForm={ - isFeedbackOpen && !feedbackSubmitted && sentiment !== undefined - ? { - className: 'ols-plugin__feedback', - hasTextArea: true, - headingLevel: 'h6', - onClose: onFeedbackClose, - onSubmit: onFeedbackSubmit, - onTextAreaChange: onFeedbackTextChange, - submitWord: t('Submit'), - textAreaProps: { value: feedbackText ?? '' }, - title: t( - "Do not include personal information or other sensitive information in your feedback. Feedback may be used to improve Red Hat's products or services.", - ), - } - : undefined - } - /> - ); - } + {aiTools && } + + ), + endContent: feedbackError ? ( + + {feedbackError.moreInfo ? feedbackError.moreInfo : feedbackError.message} + + ) : undefined, + }} + hasRoundAvatar={false} + isCompact + isLoading={!entry.text && !entry.isCancelled && !entry.error} + name="OpenShift Lightspeed" + reactMarkdownProps={linkedResourceMarkdown.reactMarkdownProps} + role="bot" + sources={sources} + timestamp=" " + userFeedbackComplete={ + isFeedbackOpen && feedbackSubmitted ? { onClose: onFeedbackClose } : undefined + } + userFeedbackForm={ + isFeedbackOpen && !feedbackSubmitted && sentiment !== undefined + ? { + className: 'ols-plugin__feedback', + hasTextArea: true, + headingLevel: 'h6', + onClose: onFeedbackClose, + onSubmit: onFeedbackSubmit, + onTextAreaChange: onFeedbackTextChange, + submitWord: t('Submit'), + textAreaProps: { value: feedbackText ?? '' }, + title: t( + "Do not include personal information or other sensitive information in your feedback. Feedback may be used to improve Red Hat's products or services.", + ), + } + : undefined + } + /> + ); + } - if (entry.who === 'user') { - return ( - -
{entry.text}
- {entry.attachments && Object.keys(entry.attachments).length > 0 && ( - - {t('Context')} {Object.keys(entry.attachments).length} - - } - > - {Object.keys(entry.attachments).map((key: string) => { - const attachment: Attachment = entry.attachments[key]; - return ; - })} - - )} - - ), - }} - hasRoundAvatar={false} - isCompact - name="You" - role="user" - timestamp=" " - /> - ); - } + if (entry.who === 'user') { + return ( + +
{entry.text}
+ {entry.attachments && Object.keys(entry.attachments).length > 0 && ( + + {t('Context')} {Object.keys(entry.attachments).length} + + } + > + {Object.keys(entry.attachments).map((key: string) => { + const attachment: Attachment = entry.attachments[key]; + return ; + })} + + )} + + ), + }} + hasRoundAvatar={false} + isCompact + name="You" + role="user" + timestamp=" " + /> + ); + } - return null; -}); + return null; + }, +); ChatHistoryEntry.displayName = 'ChatHistoryEntry'; type AuthAlertProps = { @@ -494,6 +505,9 @@ const GeneralPage: React.FC = ({ const conversationID: string = useSelector((s: State) => s.plugins?.ols?.get('conversationID')); + const [k8sModels, modelsInFlight] = useK8sModels(); + const modelsLoaded = modelsInFlight === false; + const [authStatus] = useAuth(); const [isFirstTimeUser] = useFirstTimeUser(); @@ -638,7 +652,9 @@ const GeneralPage: React.FC = ({ )) .toArray()} diff --git a/src/components/InlineLinkedResource.tsx b/src/components/InlineLinkedResource.tsx new file mode 100644 index 00000000..39b595ff --- /dev/null +++ b/src/components/InlineLinkedResource.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { Button } from '@patternfly/react-core'; + +import LinkedResourceStatus from './LinkedResourceStatus'; +import ResourceIcon from './ResourceIcon'; +import { buildResourceConsolePath, getResourceIconKind, K8sModelRef } from '../pageContext'; +import { shouldShowLinkedResourceStatus } from '../linkedResourceWatch'; +import { ResourceRef, resourceRefKey } from '../resourceRefs'; + +import './linked-resources.css'; + +type InlineLinkedResourceProps = { + children: React.ReactNode; + k8sModels: Record; + navigate: (path: string) => void; + resourceRef: ResourceRef; +}; + +const InlineLinkedResource: React.FC = ({ + children, + k8sModels, + navigate, + resourceRef, +}) => { + const path = buildResourceConsolePath(resourceRef, k8sModels); + const iconKind = getResourceIconKind(resourceRef, k8sModels); + + return ( + + + {shouldShowLinkedResourceStatus(resourceRef, k8sModels) && ( + + )} + + ); +}; + +export default InlineLinkedResource; diff --git a/src/components/LinkedResourceStatus.tsx b/src/components/LinkedResourceStatus.tsx new file mode 100644 index 00000000..a11eedfa --- /dev/null +++ b/src/components/LinkedResourceStatus.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + K8sResourceKind, + StatusComponent, + useK8sWatchResource, +} from '@openshift-console/dynamic-plugin-sdk'; +import { Badge, Spinner } from '@patternfly/react-core'; + +import { getLinkedResourceStatusDisplay } from '../linkedResourceStatusDisplay'; +import { buildResourceWatchProps, matchesResourceRef } from '../linkedResourceWatch'; +import { getModelKindName, K8sModelRef, resolveRefModelKeyOrKind } from '../pageContext'; +import { ResourceRef } from '../resourceRefs'; +import { getResourceStatusSummaryForRef, isInformativeStatusLabel } from '../resourceStatus'; + +type LinkedResourceStatusProps = { + k8sModels: Record; + resourceRef: ResourceRef; +}; + +const LinkedResourceStatus: React.FC = ({ k8sModels, resourceRef }) => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + const watchProps = buildResourceWatchProps(resourceRef, k8sModels); + + const [resource, loaded, loadError] = useK8sWatchResource(watchProps); + + const hasMatchingResource = matchesResourceRef(resource, resourceRef); + + if (!watchProps) { + return null; + } + + if (loadError || (loaded && !hasMatchingResource)) { + return null; + } + + if (!loaded || !hasMatchingResource) { + return ; + } + + const modelKey = resolveRefModelKeyOrKind(resourceRef, k8sModels); + const kindName = getModelKindName(modelKey, k8sModels); + const status = getResourceStatusSummaryForRef(modelKey, k8sModels, resource); + if (!isInformativeStatusLabel(status.label)) { + return null; + } + + const display = getLinkedResourceStatusDisplay(status, kindName, modelKey); + if (display.mode === 'icon') { + return ( + + + + ); + } + + return ( + + {status.label} + + ); +}; + +export default LinkedResourceStatus; diff --git a/src/components/ResourceIcon.tsx b/src/components/ResourceIcon.tsx index acd2ef6c..b579343f 100644 --- a/src/components/ResourceIcon.tsx +++ b/src/components/ResourceIcon.tsx @@ -2,11 +2,12 @@ import * as React from 'react'; import { ResourceIcon as SDKResourceIcon } from '@openshift-console/dynamic-plugin-sdk'; type Props = { + className?: string; kind: string; }; -const ResourceIcon: React.FC = ({ kind }) => ( - +const ResourceIcon: React.FC = ({ className, kind }) => ( + ); export default ResourceIcon; diff --git a/src/components/linked-resources.css b/src/components/linked-resources.css new file mode 100644 index 00000000..bf15124d --- /dev/null +++ b/src/components/linked-resources.css @@ -0,0 +1,42 @@ +.ols-plugin__inline-linked-resource { + align-items: center; + display: inline-flex; + white-space: normal; +} + +.ols-plugin__inline-linked-resource-link { + font-weight: inherit; + padding: 0; + vertical-align: baseline; +} + +.ols-plugin__inline-linked-resource-content { + align-items: center; + display: inline-flex; + gap: var(--pf-t--global--spacer--xs); +} + +.ols-plugin__inline-linked-resource-kind-icon { + margin-inline-end: 0; +} + +.ols-plugin__inline-linked-resource-name { + display: inline; +} + +.ols-plugin__inline-linked-resource > * + * { + margin-inline-start: var(--pf-t--global--spacer--xs); + vertical-align: middle; +} + +.ols-plugin__inline-linked-resource .ols-plugin__linked-resource-status-icon { + display: inline-flex; +} + +.ols-plugin__linked-resource-status { + font-size: var(--pf-t--global--font--size--sm); + max-inline-size: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/consoleNavigation.ts b/src/consoleNavigation.ts new file mode 100644 index 00000000..15811657 --- /dev/null +++ b/src/consoleNavigation.ts @@ -0,0 +1,19 @@ +/** + * Navigate the host OpenShift console without assuming a specific react-router mount. + * Works from the Lightspeed modal launched via useModal. + */ +export const navigateToConsolePath = (path: string): void => { + if (!path || path === '#') { + return; + } + + const url = path.startsWith('/') ? path : `/${path}`; + + if (window.history?.pushState) { + window.history.pushState(window.history.state, '', url); + window.dispatchEvent(new PopStateEvent('popstate')); + return; + } + + window.location.assign(url); +}; diff --git a/src/crStatus.ts b/src/crStatus.ts new file mode 100644 index 00000000..e5d1125a --- /dev/null +++ b/src/crStatus.ts @@ -0,0 +1,214 @@ +import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; + +import { StatusSummary } from './resourceStatus'; + +type GenericCondition = { + lastTransitionTime?: string; + message?: string; + phase?: string; + reason?: string; + status?: string; + type?: string; +}; + +const SUCCESS_PHASES = new Set([ + 'Active', + 'Available', + 'Bound', + 'Complete', + 'Completed', + 'Healthy', + 'Installed', + 'Online', + 'Ready', + 'Running', + 'Succeeded', + 'True', +]); + +const WARNING_PHASES = new Set([ + 'InstallReady', + 'Installing', + 'NeedsReinstall', + 'Pending', + 'Progressing', + 'Terminating', + 'Unknown', + 'Waiting', +]); + +const DANGER_PHASES = new Set([ + 'Degraded', + 'Error', + 'Expired', + 'Failed', + 'False', + 'Lost', + 'Released', + 'Unhealthy', +]); + +const DANGER_REASONS = new Set([ + 'ComponentUnhealthy', + 'RequirementsNotMet', + 'InstallComponentFailed', + 'Failed', +]); + +export const variantForPhase = (phase: string, reason?: string): StatusSummary['variant'] => { + if (SUCCESS_PHASES.has(phase)) { + return 'success'; + } + if (DANGER_PHASES.has(phase) || (reason && DANGER_REASONS.has(reason))) { + return 'danger'; + } + if (WARNING_PHASES.has(phase)) { + return 'warning'; + } + return 'info'; +}; + +const formatStatusLabel = (primary: string, secondary?: string): string => { + if (!secondary || secondary === primary) { + return primary; + } + return `${primary} (${secondary})`; +}; + +const isStandardKubeCondition = (condition: GenericCondition): boolean => + typeof condition.type === 'string' && typeof condition.status === 'string'; + +const isPhaseCondition = (condition: GenericCondition): boolean => + typeof condition.phase === 'string' && !condition.type; + +const conditionTimestamp = (condition: GenericCondition): number => { + const raw = condition.lastTransitionTime; + if (!raw) { + return 0; + } + const parsed = new Date(raw).getTime(); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const pickLatestCondition = (conditions: GenericCondition[]): GenericCondition | null => { + if (conditions.length === 0) { + return null; + } + + return [...conditions].sort( + (left, right) => conditionTimestamp(right) - conditionTimestamp(left), + )[0]; +}; + +const getTopLevelStatus = ( + status?: K8sResourceKind['status'], +): { message?: string; phase?: string; reason?: string } => { + if (!status || typeof status !== 'object') { + return {}; + } + + const record = status as Record; + const phase = typeof record.phase === 'string' ? record.phase : undefined; + const reason = typeof record.reason === 'string' ? record.reason : undefined; + const message = typeof record.message === 'string' ? record.message : undefined; + const state = typeof record.state === 'string' ? record.state : undefined; + + return { + message, + phase: phase ?? state, + reason, + }; +}; + +const variantForStandardCondition = (status?: string): StatusSummary['variant'] => { + if (status === 'True') { + return 'success'; + } + if (status === 'False') { + return 'danger'; + } + return 'warning'; +}; + +const getStandardConditionStatus = (conditions: GenericCondition[]): StatusSummary | null => { + const standard = conditions.filter(isStandardKubeCondition); + if (standard.length === 0) { + return null; + } + + const priority = ['Ready', 'Available', 'Progressing', 'Degraded', 'Failure']; + for (const type of priority) { + const condition = standard.find((entry) => entry.type === type); + if (!condition) { + continue; + } + const label = condition.reason || condition.type || type; + if (type === 'Degraded' || type === 'Failure') { + return { + label, + variant: condition.status === 'True' ? 'danger' : 'success', + }; + } + return { + label, + variant: variantForStandardCondition(condition.status), + }; + } + + const latest = pickLatestCondition(standard); + if (!latest) { + return null; + } + + return { + label: formatStatusLabel(latest.reason || latest.type || 'Unknown', latest.type), + variant: variantForStandardCondition(latest.status), + }; +}; + +const getPhaseConditionStatus = (conditions: GenericCondition[]): StatusSummary | null => { + const phaseConditions = conditions.filter(isPhaseCondition); + if (phaseConditions.length === 0) { + return null; + } + + const latest = pickLatestCondition(phaseConditions); + if (!latest?.phase) { + return null; + } + + return { + label: formatStatusLabel(latest.phase, latest.reason), + variant: variantForPhase(latest.phase, latest.reason), + }; +}; + +export const getGenericResourceStatus = (resource?: K8sResourceKind): StatusSummary => { + if (!resource?.status) { + return { label: '—', variant: 'info' }; + } + + const topLevel = getTopLevelStatus(resource.status); + if (topLevel.phase) { + return { + label: formatStatusLabel(topLevel.phase, topLevel.reason), + variant: variantForPhase(topLevel.phase, topLevel.reason), + }; + } + + const conditions = resource.status.conditions; + if (Array.isArray(conditions) && conditions.length > 0) { + const typedConditions = conditions as GenericCondition[]; + const fromStandard = getStandardConditionStatus(typedConditions); + if (fromStandard) { + return fromStandard; + } + + const fromPhase = getPhaseConditionStatus(typedConditions); + if (fromPhase) { + return fromPhase; + } + } + + return { label: 'Live', variant: 'info' }; +}; diff --git a/src/hooks/useConsoleNavigation.ts b/src/hooks/useConsoleNavigation.ts new file mode 100644 index 00000000..e2e7a890 --- /dev/null +++ b/src/hooks/useConsoleNavigation.ts @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { useNavigate } from 'react-router'; + +import { navigateToConsolePath } from '../consoleNavigation'; + +export const useConsoleNavigation = (): ((path: string) => void) => { + const navigate = useNavigate(); + + return React.useCallback( + (path: string) => { + if (!path || path === '#') { + return; + } + + try { + navigate(path); + } catch { + navigateToConsolePath(path); + } + }, + [navigate], + ); +}; diff --git a/src/hooks/useLinkedResourceMarkdown.tsx b/src/hooks/useLinkedResourceMarkdown.tsx new file mode 100644 index 00000000..e6017aa8 --- /dev/null +++ b/src/hooks/useLinkedResourceMarkdown.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import type { MessageProps } from '@patternfly/chatbot'; + +import InlineLinkedResource from '../components/InlineLinkedResource'; +import { useConsoleNavigation } from '../hooks/useConsoleNavigation'; +import { getInlineLinkedResources } from '../linkedResources'; +import { injectResourceLinksInMarkdown } from '../linkedResourceText'; +import { buildResourceConsolePath, K8sModelRef } from '../pageContext'; +import { ResourceRef, resourceRefKey } from '../resourceRefs'; +import { Tool } from '../types'; + +type ReactMarkdownProps = NonNullable; + +type UseLinkedResourceMarkdownOptions = { + enabled: boolean; + k8sModels: Record; + responseText?: string; + tools?: Record; +}; + +type UseLinkedResourceMarkdownResult = { + content?: string; + reactMarkdownProps?: ReactMarkdownProps; +}; + +export const useLinkedResourceMarkdown = ({ + enabled, + k8sModels, + responseText, + tools, +}: UseLinkedResourceMarkdownOptions): UseLinkedResourceMarkdownResult => { + const navigate = useConsoleNavigation(); + + const linkedRefs = React.useMemo( + () => (enabled ? getInlineLinkedResources(tools, responseText, k8sModels) : []), + [enabled, k8sModels, responseText, tools], + ); + + const refsByPath = React.useMemo(() => { + const map = new Map(); + for (const ref of linkedRefs) { + const path = buildResourceConsolePath(ref, k8sModels); + if (path) { + map.set(path, ref); + } + } + return map; + }, [k8sModels, linkedRefs]); + + const content = React.useMemo(() => { + if (!enabled || !responseText) { + return responseText; + } + return injectResourceLinksInMarkdown(responseText, linkedRefs, k8sModels); + }, [enabled, k8sModels, linkedRefs, responseText]); + + const reactMarkdownProps = React.useMemo((): ReactMarkdownProps | undefined => { + if (!enabled || linkedRefs.length === 0) { + return undefined; + } + + return { + components: { + a: ({ children, href, ...props }: React.ComponentPropsWithoutRef<'a'>) => { + const resourceRef = href ? refsByPath.get(href) : undefined; + if (resourceRef) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); + }, + }, + }; + }, [enabled, k8sModels, linkedRefs, navigate, refsByPath]); + + return { content, reactMarkdownProps }; +}; diff --git a/src/hooks/useMessageSources.tsx b/src/hooks/useMessageSources.tsx new file mode 100644 index 00000000..01afd70d --- /dev/null +++ b/src/hooks/useMessageSources.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { useTranslation } from 'react-i18next'; +import type { SourcesCardProps } from '@patternfly/chatbot'; + +import { ReferencedDoc } from '../types'; + +const isURL = (value: string): boolean => { + try { + const url = new URL(value); + return !!(url.protocol && url.host); + } catch { + return false; + } +}; + +const buildDocSources = (references: ReferencedDoc[] | undefined): SourcesCardProps['sources'] => + (references ?? []) + .filter( + (reference) => + reference && + typeof reference.doc_title === 'string' && + typeof reference.doc_url === 'string' && + isURL(reference.doc_url), + ) + .map((reference) => ({ + isExternal: true, + link: reference.doc_url, + title: reference.doc_title, + })); + +type UseMessageSourcesOptions = { + enabled: boolean; + references?: ReferencedDoc[]; +}; + +type UseMessageSourcesResult = { + messageSources?: SourcesCardProps; +}; + +export const useMessageSources = ({ + enabled, + references, +}: UseMessageSourcesOptions): UseMessageSourcesResult => { + const { t } = useTranslation('plugin__lightspeed-console-plugin'); + + const docSources = React.useMemo( + () => (enabled ? buildDocSources(references) : []), + [enabled, references], + ); + + const messageSources = React.useMemo((): SourcesCardProps | undefined => { + if (docSources.length === 0) { + return undefined; + } + + return { + paginationAriaLabel: t('Evidence sources'), + sourceWord: t('source'), + sourceWordPlural: t('sources'), + sources: docSources, + toNextPageAriaLabel: t('Next source'), + toPreviousPageAriaLabel: t('Previous source'), + }; + }, [docSources, t]); + + return { messageSources }; +}; diff --git a/src/linkedResourceStatusDisplay.ts b/src/linkedResourceStatusDisplay.ts new file mode 100644 index 00000000..cb1191c7 --- /dev/null +++ b/src/linkedResourceStatusDisplay.ts @@ -0,0 +1,138 @@ +import { StatusSummary } from './resourceStatus'; + +/** + * StatusComponent maps `status` strings to icons in a switch statement; those case + * values are not exported from @openshift-console/dynamic-plugin-sdk. + * @see node_modules/@openshift-console/dynamic-plugin-sdk/lib/app/components/status/Status.js + */ + +/** Built-in kinds with custom summary text that should stay as text badges. */ +const TEXT_ONLY_BUILTIN_KINDS = new Set(['HorizontalPodAutoscaler']); + +/** Container waiting/terminated reasons that StatusComponent renders with an icon. */ +const CONTAINER_REASONS_WITH_ICON = new Set([ + 'ContainerCannotRun', + 'ContainerCreating', + 'CrashLoopBackOff', + 'ErrImagePull', + 'Error', + 'ImagePullBackOff', +]); + +export type LinkedResourceStatusDisplay = + | { mode: 'icon'; status: string; title: string } + | { mode: 'text' }; + +export type ConsoleStatusMapping = { + status: string; + useIcon: boolean; +}; + +export const usesConsoleStatusIcon = (kindName: string, modelKey: string): boolean => { + if (modelKey.includes('~')) { + return false; + } + return !TEXT_ONLY_BUILTIN_KINDS.has(kindName); +}; + +const mapReplicaLabel = (summary: StatusSummary): string => { + if (summary.variant === 'success') { + return 'Running'; + } + if (summary.variant === 'warning') { + return 'Warning'; + } + return 'Failed'; +}; + +const withIcon = (status: string): ConsoleStatusMapping => ({ status, useIcon: true }); +const asText = (status: string): ConsoleStatusMapping => ({ status, useIcon: false }); + +export const toConsoleStatusKey = ( + summary: StatusSummary, + kindName: string, +): ConsoleStatusMapping => { + const { label, variant } = summary; + + if (label === 'NotReady') { + return withIcon('Not Ready'); + } + + if (CONTAINER_REASONS_WITH_ICON.has(label)) { + return withIcon(label); + } + + if (label.endsWith(' ready')) { + return withIcon(mapReplicaLabel(summary)); + } + + if (label === 'LoadBalancer ready') { + return withIcon('Ready'); + } + + if (kindName === 'Job') { + if (variant === 'danger') { + return withIcon('Failed'); + } + if (label === 'Complete' || variant === 'success') { + return withIcon('Complete'); + } + } + + if (kindName === 'CronJob') { + if (label === 'Suspended') { + return withIcon('Warning'); + } + if (label.endsWith(' active')) { + return withIcon('Running'); + } + if (label === 'Scheduled') { + return withIcon('Ready'); + } + if (label === 'No runs yet') { + return withIcon('Info'); + } + } + + if ( + ['Running', 'Succeeded', 'Ready', 'Bound', 'Complete', 'Failed', 'Pending', 'Unknown'].includes( + label, + ) + ) { + return withIcon(label); + } + + // Unmapped condition/reason text — text badge (CRD phases, HPA reasons, etc.) + if (kindName !== 'Job' && kindName !== 'CronJob') { + return asText(label); + } + + if (variant === 'success') { + return withIcon('Ready'); + } + if (variant === 'warning') { + return withIcon('Warning'); + } + if (variant === 'danger') { + return withIcon('Failed'); + } + + return asText(label); +}; + +export const getLinkedResourceStatusDisplay = ( + summary: StatusSummary, + kindName: string, + modelKey: string, +): LinkedResourceStatusDisplay => { + if (!usesConsoleStatusIcon(kindName, modelKey)) { + return { mode: 'text' }; + } + + const mapped = toConsoleStatusKey(summary, kindName); + if (!mapped.useIcon) { + return { mode: 'text' }; + } + + return { mode: 'icon', status: mapped.status, title: summary.label }; +}; diff --git a/src/linkedResourceText.ts b/src/linkedResourceText.ts new file mode 100644 index 00000000..1c868bec --- /dev/null +++ b/src/linkedResourceText.ts @@ -0,0 +1,147 @@ +import { buildResourceConsolePath, K8sModelRef } from './pageContext'; +import { isVerticalListNoiseLine } from './resourceListParsing'; +import { ResourceRef } from './resourceRefs'; + +const CODE_FENCE_PATTERN = /```[\s\S]*?```/g; +const INLINE_CODE_PATTERN = /`[^`\n]+`/g; +const MARKDOWN_LINK_PATTERN = /\[[^\]]*\]\([^)]*\)/g; + +type Replacement = { end: number; replacement: string; start: number }; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const getProtectedRanges = (text: string): [number, number][] => { + const ranges: [number, number][] = []; + for (const pattern of [CODE_FENCE_PATTERN, INLINE_CODE_PATTERN, MARKDOWN_LINK_PATTERN]) { + pattern.lastIndex = 0; + for (const match of text.matchAll(pattern)) { + if (match.index === undefined) { + continue; + } + ranges.push([match.index, match.index + match[0].length]); + } + } + + let offset = 0; + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed && isVerticalListNoiseLine(trimmed)) { + ranges.push([offset, offset + line.length]); + } + offset += line.length + 1; + } + + return ranges; +}; + +const isProtected = (start: number, end: number, ranges: [number, number][]): boolean => + ranges.some(([rangeStart, rangeEnd]) => start >= rangeStart && end <= rangeEnd); + +const overlapsReplacement = (start: number, end: number, replacements: Replacement[]): boolean => + replacements.some((existing) => start < existing.end && end > existing.start); + +const collectReplacementsForRef = ( + text: string, + ref: ResourceRef, + path: string, + protectedRanges: [number, number][], + existing: Replacement[], +): Replacement[] => { + const replacements: Replacement[] = []; + const escaped = escapeRegExp(ref.name); + + const nameFieldPattern = new RegExp(`(^|\\n)(Name:\\s*)(${escaped})(?=\\s*(?:\\n|$))`, 'gi'); + for (const match of text.matchAll(nameFieldPattern)) { + if (match.index === undefined || match[3] === undefined) { + continue; + } + const prefixLength = match[1].length + match[2].length; + const start = match.index + prefixLength; + const end = start + match[3].length; + if ( + !isProtected(start, end, protectedRanges) && + !overlapsReplacement(start, end, existing) && + !overlapsReplacement(start, end, replacements) + ) { + replacements.push({ + end, + replacement: `[${ref.name}](${path})`, + start, + }); + } + } + + const boldPattern = new RegExp(`\\*\\*(${escaped})\\*\\*`, 'g'); + for (const match of text.matchAll(boldPattern)) { + if (match.index === undefined) { + continue; + } + const start = match.index; + const end = start + match[0].length; + if ( + !isProtected(start, end, protectedRanges) && + !overlapsReplacement(start, end, existing) && + !overlapsReplacement(start, end, replacements) + ) { + replacements.push({ + end, + replacement: `[${ref.name}](${path})`, + start, + }); + } + } + + const standalonePattern = new RegExp(`(^|\\n)(${escaped})(?=\\n|$)`, 'g'); + for (const match of text.matchAll(standalonePattern)) { + if (match.index === undefined || match[2] === undefined) { + continue; + } + const start = match.index + match[1].length; + const end = start + match[2].length; + if ( + !isProtected(start, end, protectedRanges) && + !overlapsReplacement(start, end, existing) && + !overlapsReplacement(start, end, replacements) + ) { + replacements.push({ + end, + replacement: `[${ref.name}](${path})`, + start, + }); + } + } + + return replacements; +}; + +export const injectResourceLinksInMarkdown = ( + text: string, + refs: ResourceRef[], + models: Record, +): string => { + if (!text || refs.length === 0) { + return text; + } + + const protectedRanges = getProtectedRanges(text); + const sortedRefs = [...refs].sort((a, b) => b.name.length - a.name.length); + const allReplacements: Replacement[] = []; + + for (const ref of sortedRefs) { + const path = buildResourceConsolePath(ref, models); + if (!path) { + continue; + } + allReplacements.push( + ...collectReplacementsForRef(text, ref, path, protectedRanges, allReplacements), + ); + } + + let result = text; + for (const replacement of allReplacements.sort((a, b) => b.start - a.start)) { + result = + result.slice(0, replacement.start) + replacement.replacement + result.slice(replacement.end); + } + + return result; +}; diff --git a/src/linkedResourceWatch.ts b/src/linkedResourceWatch.ts new file mode 100644 index 00000000..ab76ac2a --- /dev/null +++ b/src/linkedResourceWatch.ts @@ -0,0 +1,76 @@ +import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; + +import { K8sModelRef, resolveRefModelKey } from './pageContext'; +import { ResourceRef } from './resourceRefs'; + +export type ResourceWatchProps = { + isList: false; + kind: string; + name: string; + namespace?: string; +}; + +export const buildResourceWatchProps = ( + resourceRef: ResourceRef, + k8sModels: Record, +): ResourceWatchProps | null => { + const modelKey = resolveRefModelKey(resourceRef, k8sModels); + if (!modelKey) { + return null; + } + + const model = k8sModels[modelKey]; + if (model?.namespaced) { + if (!resourceRef.namespace) { + return null; + } + return { + isList: false, + kind: modelKey, + name: resourceRef.name, + namespace: resourceRef.namespace, + }; + } + + return { + isList: false, + kind: modelKey, + name: resourceRef.name, + }; +}; + +export const matchesResourceRef = ( + resource: K8sResourceKind | undefined, + resourceRef: ResourceRef, +): boolean => { + if (!resource?.metadata?.name) { + return false; + } + if (resource.metadata.name !== resourceRef.name) { + return false; + } + if ( + resourceRef.namespace !== undefined && + resource.metadata.namespace !== resourceRef.namespace + ) { + return false; + } + return true; +}; + +/** Namespace/project phase (e.g. Active) reflects lifecycle, not console selection — skip the badge. */ +const KINDS_WITHOUT_STATUS_BADGE = new Set(['Namespace', 'ConfigMap', 'Secret']); + +export const shouldShowLinkedResourceStatus = ( + resourceRef: ResourceRef, + k8sModels: Record, +): boolean => { + const modelKey = resolveRefModelKey(resourceRef, k8sModels); + if (!modelKey) { + return false; + } + if (KINDS_WITHOUT_STATUS_BADGE.has(modelKey)) { + return false; + } + return true; +}; diff --git a/src/linkedResources.ts b/src/linkedResources.ts new file mode 100644 index 00000000..0c7d0ecc --- /dev/null +++ b/src/linkedResources.ts @@ -0,0 +1,135 @@ +import { getModelKindName, K8sModelRef, resolveRefModelKey, resolveRefModelKeyOrKind } from './pageContext'; +import { hasBulkResourceListTool } from './resourceListParsing'; +import { + extractResourceRefs, + getMentionedResourceNames, + isPlausibleResourceName, + ResourceRef, +} from './resourceRefs'; +import { Tool } from './types'; + +/** Default cap for mixed resource types (deployments, single pods, etc.). */ +export const MAX_LINKED_RESOURCES = 3; + +/** Higher cap when the response is driven by a namespace pod list tool. */ +export const MAX_LINKED_RESOURCES_POD_LIST = 12; + +export const prioritizeLinkedResources = ( + refs: ResourceRef[], + responseText?: string, + tools?: Record, +): ResourceRef[] => { + const mentionedNames = getMentionedResourceNames(responseText, tools).map((name) => + name.toLowerCase(), + ); + if (mentionedNames.length === 0) { + return refs; + } + + const byName = new Map(refs.map((ref) => [ref.name.toLowerCase(), ref])); + const prioritized: ResourceRef[] = []; + const used = new Set(); + + for (const name of mentionedNames) { + const ref = byName.get(name); + if (ref) { + prioritized.push(ref); + used.add(name); + } + } + + for (const ref of refs) { + const key = ref.name.toLowerCase(); + if (!used.has(key)) { + prioritized.push(ref); + } + } + + return prioritized; +}; + +export const isPlausibleResourceRef = ( + ref: ResourceRef, + models: Record, +): boolean => { + const modelKey = resolveRefModelKeyOrKind(ref, models); + const kindName = getModelKindName(modelKey, models); + if (ref.name === kindName) { + return false; + } + return isPlausibleResourceName(ref.name); +}; + +export const isLinkableResource = ( + ref: ResourceRef, + models: Record, +): boolean => { + if (!isPlausibleResourceRef(ref, models)) { + return false; + } + + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return false; + } + + const model = models[modelKey]; + if (model.namespaced && !ref.namespace) { + return false; + } + + return true; +}; + +const resolveLinkedResourceLimit = ( + tools: Record | undefined, + linkableCount: number, +): number => { + if (hasBulkResourceListTool(tools) && linkableCount > MAX_LINKED_RESOURCES) { + return Math.min(linkableCount, MAX_LINKED_RESOURCES_POD_LIST); + } + return MAX_LINKED_RESOURCES; +}; + +export const extractLinkableResourceRefs = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): ResourceRef[] => + extractResourceRefs(tools, responseText, models).filter((ref) => isLinkableResource(ref, models)); + +/** Capped list for a future footer LabelGroup; inline links use {@link getInlineLinkedResources}. */ +export const extractLinkedResources = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): ResourceRef[] => { + const linkable = extractLinkableResourceRefs(tools, responseText, models); + const limit = resolveLinkedResourceLimit(tools, linkable.length); + return prioritizeLinkedResources(linkable, responseText, tools).slice(0, limit); +}; + +export const getLinkedResourceOverflow = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): { shown: number; total: number } => { + const linkable = extractLinkableResourceRefs(tools, responseText, models); + const shown = extractLinkedResources(tools, responseText, models).length; + return { shown, total: linkable.length }; +}; + +/** Linkable resources whose names appear in the response prose (for inline injection). */ +export const getInlineLinkedResources = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): ResourceRef[] => { + if (!responseText?.trim()) { + return []; + } + + return extractLinkableResourceRefs(tools, responseText, models).filter((ref) => + responseText.includes(ref.name), + ); +}; diff --git a/src/pageContext.ts b/src/pageContext.ts index e1cf02bd..05232c26 100644 --- a/src/pageContext.ts +++ b/src/pageContext.ts @@ -1,9 +1,25 @@ +import { isValidNamespaceName, isValidResourceName } from './validation'; + +export type K8sModelRef = { + kind?: string; + plural?: string; + namespaced?: boolean; +}; + +export type ResourceRef = { + kind: string; + name: string; + namespace?: string; + /** Use the OpenShift project route (/k8s/cluster/projects/…) instead of namespaces. */ + useProjectRoute?: boolean; +}; + // Resolve a URL resource key to a k8s model key. The URL segment can be a direct model key // (e.g. "Pod"), a plural (e.g. "pods"), or a group~version~kind reference where the model // is stored under just the kind (e.g. "core~v1~Pod" → "Pod"). export const resolveModelKey = ( urlKey: string, - models: Record, + models: Record, ): string | undefined => { if (models[urlKey]) { return urlKey; @@ -17,10 +33,136 @@ export const resolveModelKey = ( if (kindPart && models[kindPart]) { return kindPart; } + return Object.keys(models).find((key) => key === urlKey || key.endsWith(`~${kindPart}`)); } return undefined; }; +export const resolveKindToModelKey = ( + kind: string, + models: Record, +): string | undefined => { + if (kind === 'Project' && models.Namespace) { + return 'Namespace'; + } + + if (models[kind]) { + return kind; + } + + const byKindField = Object.keys(models).find((key) => models[key].kind === kind); + if (byKindField) { + return byKindField; + } + + if (kind.includes('~')) { + const kindPart = kind.split('~').pop(); + if (kindPart && models[kindPart]) { + return kindPart; + } + return Object.keys(models).find((key) => key === kind || key.endsWith(`~${kindPart}`)); + } + + return undefined; +}; + +/** Resolve a {@link ResourceRef} to a console k8s model key. */ +export const resolveRefModelKey = ( + ref: ResourceRef, + models: Record, +): string | undefined => { + if (ref.useProjectRoute && models.Namespace) { + return 'Namespace'; + } + return resolveKindToModelKey(ref.kind, models); +}; + +/** Like {@link resolveRefModelKey}, but falls back to `ref.kind` when models are unknown. */ +export const resolveRefModelKeyOrKind = ( + ref: ResourceRef, + models: Record, +): string => resolveRefModelKey(ref, models) ?? ref.kind; + +export const getModelKindName = (modelKey: string, models: Record): string => + models[modelKey]?.kind ?? modelKey.split('~').pop() ?? modelKey; + +/** Model reference for console ResourceIcon (e.g. Pod or group~version~kind for CRDs). */ +export const getResourceIconKind = ( + ref: ResourceRef, + models: Record, +): string => { + if (ref.useProjectRoute) { + return 'Project'; + } + return resolveRefModelKeyOrKind(ref, models); +}; + +export const isClusterScopedRef = ( + ref: ResourceRef, + models: Record, +): boolean => { + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return false; + } + return models[modelKey]?.namespaced === false; +}; + +export const isNamespacedRef = (ref: ResourceRef, models: Record): boolean => { + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return true; + } + return models[modelKey]?.namespaced !== false; +}; + +export const getModelUrlSegment = ( + modelKey: string, + models: Record, +): string | undefined => { + if (modelKey.includes('~')) { + return modelKey; + } + return models[modelKey]?.plural; +}; + +export const buildResourceConsolePath = ( + ref: ResourceRef, + models: Record, +): string | null => { + const { name, namespace } = ref; + if (!isValidResourceName(name)) { + return null; + } + if (namespace !== undefined && !isValidNamespaceName(namespace)) { + return null; + } + + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return null; + } + + const model = models[modelKey]; + const urlSegment = getModelUrlSegment(modelKey, models); + if (!urlSegment) { + return null; + } + + if (modelKey === 'Namespace' && ref.useProjectRoute) { + return `/k8s/cluster/projects/${name}`; + } + + if (model.namespaced) { + if (!namespace) { + return null; + } + return `/k8s/ns/${namespace}/${urlSegment}/${name}`; + } + + return `/k8s/cluster/${urlSegment}/${name}`; +}; + export const buildPageContext = ( kind: string | undefined, name: string | undefined, diff --git a/src/resourceListParsing.ts b/src/resourceListParsing.ts new file mode 100644 index 00000000..49cca746 --- /dev/null +++ b/src/resourceListParsing.ts @@ -0,0 +1,255 @@ +import { ResourceRef } from './pageContext'; +import { Tool } from './types'; + +const K8S_NODE_NAME_RE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +const RESOURCE_AGE_LINE = /^\d+([wdhms]\d*)+$/i; + +const NAMESPACE_STATUS_WORDS = new Set(['active', 'terminating']); + +const VERTICAL_LIST_SKIP_WORDS = new Set([ + 'active', + 'terminating', + 'running', + 'pending', + 'succeeded', + 'failed', + 'unknown', + 'ready', + 'true', + 'false', + 'available', + 'progressing', + 'replicafailure', + 'worker', + 'master', + 'control-plane', +]); + +const READY_FRACTION_LINE = /^\d+\/\d+$/; +const PERCENT_LINE = /^\d+%$/; +const IP_LINE = /^(?:\d{1,3}\.){3}\d{1,3}$/; + +const RESOURCE_LIST_TOOL_NAMES = new Set([ + 'resources_list', + 'pods_list', + 'pods_list_in_namespace', + 'namespaces_list', + 'projects_list', +]); + +const isApiVersionToken = (token: string): boolean => token === 'v1' || token.includes('/'); + +const isTableHeaderLine = (line: string): boolean => /^(NAME|NAMESPACE|APIVERSION)\b/i.test(line); + +export const isNamespaceStatusLine = (line: string): boolean => + NAMESPACE_STATUS_WORDS.has(line.toLowerCase()); + +export const isVerticalListNoiseLine = (line: string): boolean => { + const lower = line.toLowerCase(); + if (RESOURCE_AGE_LINE.test(line)) { + return true; + } + if (isNamespaceStatusLine(line)) { + return true; + } + if (VERTICAL_LIST_SKIP_WORDS.has(lower)) { + return true; + } + if (READY_FRACTION_LINE.test(line)) { + return true; + } + if (PERCENT_LINE.test(line)) { + return true; + } + if (IP_LINE.test(line)) { + return true; + } + if (/^\d+$/.test(line)) { + return true; + } + return false; +}; + +export type ResourceListToolContext = { + kind: string; + namespace?: string; +}; + +export const toNamespaceRef = (ref: ResourceRef): ResourceRef => + ref.kind === 'Project' ? { kind: 'Namespace', name: ref.name, useProjectRoute: true } : ref; + +export const extractResourcesFromMcpTableContent = (content: string): ResourceRef[] => { + const refs: ResourceRef[] = []; + const seen = new Set(); + + const add = (ref: ResourceRef) => { + const normalized = toNamespaceRef(ref); + const key = `${normalized.kind}/${normalized.namespace ?? ''}/${normalized.name}`; + if (seen.has(key)) { + return; + } + seen.add(key); + refs.push(normalized); + }; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || isTableHeaderLine(trimmed)) { + continue; + } + + const parts = trimmed.split(/\s+/); + if (parts.length < 3) { + continue; + } + + if (isApiVersionToken(parts[0])) { + const kind = parts[1]; + const name = parts[2]; + add({ kind, name }); + continue; + } + + if (parts.length >= 4 && isApiVersionToken(parts[1])) { + const namespace = parts[0]; + const kind = parts[2]; + const name = parts[3]; + add({ kind, name, namespace }); + } + } + + return refs; +}; + +export const applyListToolNamespaceDefault = ( + refs: ResourceRef[], + namespace?: string, +): ResourceRef[] => { + if (!namespace) { + return refs; + } + return refs.map((ref) => (ref.namespace ? ref : { ...ref, namespace })); +}; + +export const resourceListToolContextFromTool = (tool: Tool): ResourceListToolContext | null => { + if (tool.isDenied || tool.status === 'error') { + return null; + } + + const args = tool.args ?? {}; + + switch (tool.name) { + case 'resources_list': { + const kind = typeof args.kind === 'string' ? args.kind : undefined; + if (!kind) { + return null; + } + const namespace = typeof args.namespace === 'string' ? args.namespace : undefined; + return { kind, namespace }; + } + case 'pods_list': + return { kind: 'Pod' }; + case 'pods_list_in_namespace': { + const namespace = typeof args.namespace === 'string' ? args.namespace : undefined; + if (!namespace) { + return null; + } + return { kind: 'Pod', namespace }; + } + case 'namespaces_list': + case 'projects_list': + return { kind: 'Namespace' }; + case 'nodes_top': + if (typeof args.name === 'string' && args.name) { + return null; + } + return { kind: 'Node' }; + default: + return null; + } +}; + +export const getResourceListToolContexts = ( + tools?: Record, +): ResourceListToolContext[] => { + const contexts: ResourceListToolContext[] = []; + const seen = new Set(); + + for (const tool of Object.values(tools ?? {})) { + const context = resourceListToolContextFromTool(tool); + if (!context) { + continue; + } + const key = `${context.kind}/${context.namespace ?? ''}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + contexts.push(context); + } + + return contexts; +}; + +export const isResourceListToolName = (toolName?: string): boolean => + !!toolName && RESOURCE_LIST_TOOL_NAMES.has(toolName); + +export const hasBulkResourceListTool = (tools?: Record): boolean => + getResourceListToolContexts(tools).length > 0; + +const lineMatchesListContext = (line: string, context: ResourceListToolContext): boolean => { + if (context.kind === 'Node') { + return line.includes('.') && K8S_NODE_NAME_RE.test(line); + } + if (context.kind === 'Namespace') { + return !isNamespaceStatusLine(line); + } + return true; +}; + +export const extractResourcesFromVerticalListing = ( + text: string, + contexts: ResourceListToolContext[], + isPlausibleName: (name: string) => boolean, +): ResourceRef[] => { + if (contexts.length === 0) { + return []; + } + + const refs: ResourceRef[] = []; + const seen = new Set(); + + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.includes(' ')) { + continue; + } + if (isVerticalListNoiseLine(trimmed)) { + continue; + } + if (!isPlausibleName(trimmed)) { + continue; + } + + for (const context of contexts) { + if (!lineMatchesListContext(trimmed, context)) { + continue; + } + const ref: ResourceRef = { + kind: context.kind, + name: trimmed, + namespace: context.namespace, + }; + const key = `${ref.kind}/${ref.namespace ?? ''}/${ref.name}`; + if (seen.has(key)) { + break; + } + seen.add(key); + refs.push(ref); + break; + } + } + + return refs; +}; diff --git a/src/resourceRefs.ts b/src/resourceRefs.ts new file mode 100644 index 00000000..ea061d68 --- /dev/null +++ b/src/resourceRefs.ts @@ -0,0 +1,677 @@ +import { load as loadYAML } from 'js-yaml'; + +import { K8sModelRef, resolveKindToModelKey, resolveRefModelKey, ResourceRef } from './pageContext'; +import { + applyListToolNamespaceDefault, + extractResourcesFromMcpTableContent, + extractResourcesFromVerticalListing, + getResourceListToolContexts, + hasBulkResourceListTool, + isResourceListToolName, + isVerticalListNoiseLine, + toNamespaceRef, +} from './resourceListParsing'; +import { Tool } from './types'; + +export type { ResourceRef } from './pageContext'; +export { buildResourceConsolePath } from './pageContext'; + +const TEXT_RESOURCE_PATTERN = /\b([A-Z][a-zA-Z0-9]+)\s+[`'"]?([a-z0-9][a-z0-9.-]*)/g; + +const POD_NAME_FIELD_PATTERN = /\bName:\s*([a-z0-9][a-z0-9.-]*)/gi; + +const POD_NAME_SUFFIX_PATTERN = /\b([a-z0-9][a-z0-9.-]*)\s+pods?\b/gi; + +const NAMESPACE_PATTERN = /namespace\s+[`'"]?([a-z0-9][a-z0-9-]*)[`'"]?/gi; + +const K8S_RESOURCE_NAME_RE = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/; + +const RESOURCE_NAME_STOP_WORDS = new Set([ + 'is', + 'will', + 'object', + 'not', + 'has', + 'are', + 'was', + 'were', + 'been', + 'being', + 'the', + 'and', + 'for', + 'that', + 'this', + 'when', + 'more', + 'some', + 'any', + 'all', + 'can', + 'may', + 'our', + 'out', + 'own', + 'too', + 'very', + 'just', + 'now', + 'then', + 'than', + 'also', + 'into', + 'over', + 'such', + 'only', + 'other', + 'about', + 'after', + 'before', + 'between', + 'during', + 'without', + 'within', + 'never', + 'always', + 'properly', + 'configured', + 'means', + 'need', + 'check', + 'consider', + 'failure', + 'ensure', + 'working', + 'active', + 'finish', + 'updating', + 'unavailable', +]); + +export const isPlausibleResourceName = ( + name: string, + options?: { fromText?: boolean }, +): boolean => { + if (!name || name.length < 2) { + return false; + } + if (isVerticalListNoiseLine(name)) { + return false; + } + if (!K8S_RESOURCE_NAME_RE.test(name)) { + return false; + } + if (RESOURCE_NAME_STOP_WORDS.has(name)) { + return false; + } + // Prose like "ClusterVersion object" or "MachineConfigPool will" — require workload-like names. + if (options?.fromText && !/[0-9-]/.test(name)) { + return false; + } + return true; +}; + +const K8S_NODE_NAME_RE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; + +export const extractNodesFromTopContent = (content: string): ResourceRef[] => { + const refs: ResourceRef[] = []; + const seen = new Set(); + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || /^NAME\b/i.test(trimmed)) { + continue; + } + + const name = trimmed.split(/\s+/)[0]; + if (!name || !K8S_NODE_NAME_RE.test(name) || !isPlausibleResourceName(name)) { + continue; + } + + const key = name.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + refs.push({ kind: 'Node', name }); + } + + return refs; +}; + +export const extractNamespacesFromListContent = (content: string): ResourceRef[] => + extractResourcesFromMcpTableContent(content).filter((ref) => ref.kind === 'Namespace'); + +export const extractNodesFromResourcesListContent = (content: string): ResourceRef[] => + extractResourcesFromMcpTableContent(content).filter((ref) => ref.kind === 'Node'); + +export const hasNodesTopListTool = (tools?: Record): boolean => + Object.values(tools ?? {}).some((tool) => { + if (tool.isDenied || tool.status === 'error' || tool.name !== 'nodes_top') { + return false; + } + if (typeof tool.args?.name === 'string' && tool.args.name) { + return false; + } + return extractNodesFromTopContent(tool.content ?? '').length > 0; + }); + +export const hasResourceListNodesTool = (tools?: Record): boolean => + Object.values(tools ?? {}).some((tool) => { + if (tool.isDenied || tool.status === 'error' || tool.name !== 'resources_list') { + return false; + } + return tool.args?.kind === 'Node'; + }); + +export const hasBulkNodeListTool = (tools?: Record): boolean => + hasNodesTopListTool(tools) || hasResourceListNodesTool(tools); + +export const hasNamespacesListTool = (tools?: Record): boolean => + getResourceListToolContexts(tools).some((context) => context.kind === 'Namespace'); + +export const hasBulkNamespaceListTool = (tools?: Record): boolean => + hasNamespacesListTool(tools); + +export const hasPodListTool = (tools?: Record): boolean => + getResourceListToolContexts(tools).some((context) => context.kind === 'Pod'); + +type EventInvolvedObject = { + Kind?: string; + Name?: string; + Namespace?: string; + kind?: string; + name?: string; + namespace?: string; +}; + +type EventDocument = { + InvolvedObject?: EventInvolvedObject; + involvedObject?: EventInvolvedObject; + Namespace?: string; + metadata?: { namespace?: string }; +}; + +const pickStringField = ( + record: Record, + ...keys: string[] +): string | undefined => { + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value) { + return value; + } + } + return undefined; +}; + +const extractRefsFromStructuredListItems = ( + structured: Record | undefined, + toolArgs: Record, + toolName?: string, +): ResourceRef[] => { + const items = structured?.items; + if (!Array.isArray(items)) { + return []; + } + + const defaultKind = + typeof toolArgs.kind === 'string' + ? toolArgs.kind + : toolName === 'namespaces_list' + ? 'Namespace' + : toolName === 'projects_list' + ? 'Project' + : undefined; + const defaultNamespace = typeof toolArgs.namespace === 'string' ? toolArgs.namespace : undefined; + const refs: ResourceRef[] = []; + + for (const item of items) { + if (!item || typeof item !== 'object') { + continue; + } + const row = item as Record; + const name = pickStringField(row, 'Name', 'name'); + const kind = pickStringField(row, 'kind', 'Kind') ?? defaultKind; + if (!name || !kind) { + continue; + } + const namespace = pickStringField(row, 'Namespace', 'namespace') ?? defaultNamespace; + refs.push(toNamespaceRef({ kind, name, namespace })); + } + + return refs; +}; + +const listElementKind = (listKind: string): string | undefined => + listKind.endsWith('List') ? listKind.slice(0, -4) : undefined; + +const extractRefFromObject = (obj: unknown): ResourceRef | null => { + if (!obj || typeof obj !== 'object') { + return null; + } + const record = obj as { kind?: string; metadata?: { name?: string; namespace?: string } }; + const kind = record.kind; + const name = record.metadata?.name; + const namespace = record.metadata?.namespace; + if (kind && name) { + return { kind, name, namespace }; + } + return null; +}; + +const extractRefsFromDocumentArray = (parsed: unknown): ResourceRef[] => { + if (!Array.isArray(parsed)) { + return []; + } + + const refs: ResourceRef[] = []; + for (const item of parsed) { + const ref = extractRefFromObject(item); + if (ref) { + refs.push(toNamespaceRef(ref)); + } + } + return refs; +}; + +export const extractRefsFromEventsDocument = (parsed: unknown): ResourceRef[] => { + const events = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []; + const refs: ResourceRef[] = []; + const seen = new Set(); + + for (const event of events) { + if (!event || typeof event !== 'object') { + continue; + } + const record = event as EventDocument; + const involved = record.InvolvedObject ?? record.involvedObject; + if (!involved) { + continue; + } + const kind = involved.Kind ?? involved.kind; + const name = involved.Name ?? involved.name; + if (!kind || !name || !isPlausibleResourceName(name)) { + continue; + } + const namespace = + involved.Namespace ?? involved.namespace ?? record.Namespace ?? record.metadata?.namespace; + const ref = toNamespaceRef({ kind, name, namespace }); + const key = `${ref.kind}/${ref.namespace ?? ''}/${ref.name}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + refs.push(ref); + } + + return refs; +}; + +const hasPodInvestigationTools = (tools?: Record): boolean => + Object.values(tools ?? {}).some( + (tool) => tool.name.startsWith('pods_') || tool.name === 'events_list', + ); + +const extractRefsFromListDocument = ( + parsed: { kind?: string; items?: unknown[] }, + fallbackNamespace?: string, +): ResourceRef[] => { + if (!Array.isArray(parsed.items)) { + return []; + } + const defaultKind = parsed.kind ? listElementKind(parsed.kind) : undefined; + const refs: ResourceRef[] = []; + for (const item of parsed.items) { + const ref = extractRefFromObject(item); + if (!ref) { + continue; + } + refs.push({ + kind: ref.kind ?? defaultKind ?? 'Pod', + name: ref.name, + namespace: ref.namespace ?? fallbackNamespace, + }); + } + return refs; +}; + +const resolveModelKeyFromToolPrefix = ( + toolPrefix: string, + models: Record, +): string | undefined => { + const normalized = toolPrefix.toLowerCase(); + return Object.keys(models).find((key) => { + const model = models[key]; + return ( + key.toLowerCase() === normalized || + model.kind?.toLowerCase() === normalized || + model.plural?.toLowerCase() === normalized + ); + }); +}; + +export const normalizeResourceRef = ( + ref: ResourceRef, + models: Record, +): ResourceRef | null => { + if (!isPlausibleResourceName(ref.name)) { + return null; + } + const modelKey = resolveRefModelKey(ref, models); + if (!modelKey) { + return null; + } + return { + kind: modelKey, + name: ref.name, + namespace: ref.namespace, + ...(ref.useProjectRoute ? { useProjectRoute: true } : {}), + }; +}; + +export const extractResourceFromToolArgs = ( + toolName: string, + args: Record, + models: Record, +): ResourceRef | null => { + const name = typeof args.name === 'string' ? args.name : undefined; + const namespace = typeof args.namespace === 'string' ? args.namespace : undefined; + + if (toolName === 'pods_get' || toolName === 'pods_log' || toolName === 'pods_delete') { + return name ? { kind: 'Pod', name, namespace } : null; + } + + if (toolName === 'resources_get' || toolName === 'resources_delete') { + const kind = typeof args.kind === 'string' ? args.kind : undefined; + if (kind && name) { + return { kind, name, namespace }; + } + } + + if (toolName === 'resources_list' || toolName === 'resources_scale') { + const kind = typeof args.kind === 'string' ? args.kind : undefined; + if (kind && namespace) { + return { kind, name: kind, namespace }; + } + } + + if (toolName === 'nodes_log' || toolName === 'nodes_top') { + return name ? { kind: 'Node', name } : null; + } + + if (name) { + const modelKey = resolveModelKeyFromToolPrefix(toolName.split('_')[0], models); + if (modelKey) { + return { kind: modelKey, name, namespace }; + } + } + + return null; +}; + +export const extractResourcesFromToolContent = ( + content: string, + toolName?: string, + toolArgs?: Record, +): ResourceRef[] => { + if (!content) { + return []; + } + + const fallbackNamespace = + typeof toolArgs?.namespace === 'string' ? toolArgs.namespace : undefined; + + try { + const parsed = JSON.parse(content) as unknown; + const fromArray = extractRefsFromDocumentArray(parsed); + if (fromArray.length > 0) { + return fromArray; + } + const listDoc = parsed as { kind?: string; items?: unknown[] }; + const fromList = extractRefsFromListDocument(listDoc, fallbackNamespace); + if (fromList.length > 0) { + return fromList; + } + const single = extractRefFromObject(parsed); + if (single) { + return [toNamespaceRef(single)]; + } + } catch { + // Not JSON. + } + + try { + const parsed = loadYAML(content) as unknown; + if (toolName === 'events_list') { + const fromEvents = extractRefsFromEventsDocument(parsed); + if (fromEvents.length > 0) { + return fromEvents; + } + } + const fromArray = extractRefsFromDocumentArray(parsed); + if (fromArray.length > 0) { + return fromArray; + } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const listDoc = parsed as { kind?: string; items?: unknown[] }; + const fromList = extractRefsFromListDocument(listDoc, fallbackNamespace); + if (fromList.length > 0) { + return fromList; + } + const single = extractRefFromObject(parsed); + if (single) { + return [toNamespaceRef(single)]; + } + } + } catch { + // Not YAML. + } + + if (toolName === 'nodes_top') { + return extractNodesFromTopContent(content); + } + + if (isResourceListToolName(toolName)) { + const fromTable = extractResourcesFromMcpTableContent(content); + if (fromTable.length > 0) { + return applyListToolNamespaceDefault(fromTable, fallbackNamespace); + } + } + + return []; +}; + +export const extractResourceFromToolContent = (content: string): ResourceRef | null => + extractResourcesFromToolContent(content)[0] ?? null; + +export const extractResourcesFromText = ( + text: string, + models: Record, + tools?: Record, +): ResourceRef[] => { + const refs: ResourceRef[] = []; + const namespaces = [...text.matchAll(NAMESPACE_PATTERN)].map((m) => m[1]); + const defaultNamespace = namespaces[0]; + + for (const match of text.matchAll(TEXT_RESOURCE_PATTERN)) { + const kindCandidate = match[1]; + const name = match[2]; + if (!isPlausibleResourceName(name, { fromText: true })) { + continue; + } + const modelKey = resolveKindToModelKey(kindCandidate, models); + if (!modelKey) { + continue; + } + refs.push({ + kind: modelKey, + name, + namespace: defaultNamespace, + }); + } + + const podsNamespace = tools + ? Object.values(tools).find((tool) => tool.name === 'pods_list_in_namespace')?.args?.namespace + : undefined; + const podNamespace = typeof podsNamespace === 'string' ? podsNamespace : defaultNamespace; + + if (tools && hasPodInvestigationTools(tools)) { + for (const match of text.matchAll(POD_NAME_FIELD_PATTERN)) { + const name = match[1]; + if (!isPlausibleResourceName(name)) { + continue; + } + refs.push({ + kind: 'Pod', + name, + namespace: podNamespace, + }); + } + + for (const match of text.matchAll(POD_NAME_SUFFIX_PATTERN)) { + const name = match[1]; + if (!isPlausibleResourceName(name, { fromText: true })) { + continue; + } + refs.push({ + kind: 'Pod', + name, + namespace: podNamespace, + }); + } + } + + if (tools && hasBulkResourceListTool(tools)) { + const listContexts = getResourceListToolContexts(tools); + for (const ref of extractResourcesFromVerticalListing( + text, + listContexts, + isPlausibleResourceName, + )) { + refs.push(ref); + } + } + + return refs; +}; + +const resourceKey = (ref: ResourceRef): string => `${ref.kind}/${ref.namespace ?? ''}/${ref.name}`; + +export const resourceRefKey = resourceKey; + +export const getMentionedPodNames = (responseText?: string): string[] => { + if (!responseText) { + return []; + } + + const names: string[] = []; + const seen = new Set(); + const addName = (name: string) => { + if (!isPlausibleResourceName(name, { fromText: true })) { + return; + } + const key = name.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + names.push(name); + } + }; + for (const match of responseText.matchAll(POD_NAME_FIELD_PATTERN)) { + addName(match[1]); + } + for (const match of responseText.matchAll(POD_NAME_SUFFIX_PATTERN)) { + addName(match[1]); + } + return names; +}; + +export const getMentionedNodeNames = (responseText?: string): string[] => + extractResourcesFromVerticalListing( + responseText ?? '', + [{ kind: 'Node' }], + isPlausibleResourceName, + ).map((ref) => ref.name); + +export const getMentionedResourceNames = ( + responseText?: string, + tools?: Record, +): string[] => { + const names: string[] = []; + const seen = new Set(); + const addName = (name: string) => { + const key = name.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + names.push(name); + } + }; + + for (const name of getMentionedPodNames(responseText)) { + addName(name); + } + if (hasBulkResourceListTool(tools)) { + for (const ref of extractResourcesFromVerticalListing( + responseText ?? '', + getResourceListToolContexts(tools), + isPlausibleResourceName, + )) { + addName(ref.name); + } + } + return names; +}; + +export const getMentionedNamespaceNames = (responseText?: string): string[] => + extractResourcesFromVerticalListing( + responseText ?? '', + [{ kind: 'Namespace' }], + isPlausibleResourceName, + ).map((ref) => ref.name); + +export const getPreferredResourceName = (responseText?: string): string | undefined => + getMentionedPodNames(responseText)[0]; + +export const extractResourceRefs = ( + tools: Record | undefined, + responseText: string | undefined, + models: Record, +): ResourceRef[] => { + const seen = new Set(); + const refs: ResourceRef[] = []; + + const addRef = (ref: ResourceRef | null) => { + const normalized = ref ? normalizeResourceRef(ref, models) : null; + if (!normalized) { + return; + } + const key = resourceKey(normalized); + if (seen.has(key)) { + return; + } + seen.add(key); + refs.push(normalized); + }; + + if (tools) { + Object.values(tools).forEach((tool) => { + if (tool.isDenied || tool.status === 'error') { + return; + } + const args = (tool.args ?? {}) as Record; + addRef(extractResourceFromToolArgs(tool.name, args, models)); + extractResourcesFromToolContent(tool.content, tool.name, args).forEach((ref) => { + addRef(ref); + }); + extractRefsFromStructuredListItems(tool.structuredContent, args, tool.name).forEach((ref) => { + addRef(ref); + }); + }); + } + + if (responseText) { + extractResourcesFromText(responseText, models, tools).forEach(addRef); + } + + return refs; +}; diff --git a/src/resourceStatus.ts b/src/resourceStatus.ts new file mode 100644 index 00000000..101b1e6b --- /dev/null +++ b/src/resourceStatus.ts @@ -0,0 +1,281 @@ +import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; + +import { getGenericResourceStatus, variantForPhase } from './crStatus'; +import { getModelKindName, K8sModelRef } from './pageContext'; + +export type StatusSummary = { + label: string; + variant: 'success' | 'warning' | 'danger' | 'info'; +}; + +const NON_INFORMATIVE_STATUS_LABELS = new Set(['', '-', '–', '—']); + +/** Hide badges when status text carries no useful cluster signal (e.g. em dash for status-less kinds). */ +export const isInformativeStatusLabel = (label: string): boolean => { + const trimmed = label.trim(); + return trimmed.length > 0 && !NON_INFORMATIVE_STATUS_LABELS.has(trimmed); +}; + +type ResourceCondition = { + message?: string; + reason?: string; + status?: string; + type?: string; +}; + +const statusFromPhase = (phase: string): StatusSummary => ({ + label: phase, + variant: variantForPhase(phase), +}); + +type ContainerStatus = { + ready?: boolean; + state?: { + waiting?: { reason?: string }; + terminated?: { reason?: string }; + }; +}; + +const DANGER_CONTAINER_REASONS = new Set([ + 'ContainerCannotRun', + 'CrashLoopBackOff', + 'CreateContainerConfigError', + 'CreateContainerError', + 'ErrImagePull', + 'Error', + 'ImagePullBackOff', + 'InvalidImageName', +]); + +const WARNING_CONTAINER_REASONS = new Set(['ContainerCreating', 'PodInitializing']); + +const containerReasonVariant = (reason: string): StatusSummary['variant'] => { + if (DANGER_CONTAINER_REASONS.has(reason)) { + return 'danger'; + } + if (WARNING_CONTAINER_REASONS.has(reason)) { + return 'warning'; + } + return 'warning'; +}; + +const statusFromContainerStatuses = ( + statuses: ContainerStatus[] | undefined, +): StatusSummary | undefined => { + for (const container of statuses ?? []) { + const waitingReason = container.state?.waiting?.reason; + if (waitingReason) { + return { label: waitingReason, variant: containerReasonVariant(waitingReason) }; + } + } + return undefined; +}; + +/** Match console/kubectl: container waiting/terminated reasons override pod phase. */ +const podStatus = (resource: K8sResourceKind): StatusSummary => { + const phase = resource.status?.phase ?? 'Unknown'; + + const fromInit = statusFromContainerStatuses(resource.status?.initContainerStatuses); + if (fromInit) { + return fromInit; + } + + const fromContainers = statusFromContainerStatuses(resource.status?.containerStatuses); + if (fromContainers) { + return fromContainers; + } + + for (const container of (resource.status?.containerStatuses ?? []) as ContainerStatus[]) { + const terminatedReason = container.state?.terminated?.reason; + if ( + terminatedReason && + terminatedReason !== 'Completed' && + phase !== 'Running' && + phase !== 'Succeeded' + ) { + return { + label: terminatedReason === 'Error' ? 'Error' : terminatedReason, + variant: 'danger', + }; + } + } + + if (phase === 'Running' || phase === 'Succeeded') { + return { label: phase, variant: 'success' }; + } + if (phase === 'Pending') { + return { label: phase, variant: 'warning' }; + } + if (phase === 'Failed') { + return { label: 'Failed', variant: 'danger' }; + } + return statusFromPhase(phase); +}; + +const workloadReplicaStatus = (resource: K8sResourceKind): StatusSummary => { + const ready = resource.status?.readyReplicas ?? resource.status?.numberReady ?? 0; + const total = + resource.status?.replicas ?? + resource.status?.desiredNumberScheduled ?? + resource.status?.replicas ?? + ready; + const label = `${ready}/${total} ready`; + if (ready === total && total > 0) { + return { label, variant: 'success' }; + } + if (ready > 0) { + return { label, variant: 'warning' }; + } + return { label, variant: 'danger' }; +}; + +const jobStatus = (resource: K8sResourceKind): StatusSummary => { + const conditions = resource.status?.conditions as ResourceCondition[] | undefined; + const failed = resource.status?.failed ?? 0; + const active = resource.status?.active ?? 0; + + if (failed > 0) { + return { label: 'Failed', variant: 'danger' }; + } + + const failedCondition = conditions?.find( + (condition) => condition.type === 'Failed' && condition.status === 'True', + ); + if (failedCondition) { + return { label: failedCondition.reason || 'Failed', variant: 'danger' }; + } + + const completeCondition = conditions?.find( + (condition) => condition.type === 'Complete' && condition.status === 'True', + ); + if (completeCondition) { + return { label: completeCondition.reason || 'Complete', variant: 'success' }; + } + + if (active > 0) { + return { label: 'Running', variant: 'info' }; + } + + return { label: 'Pending', variant: 'warning' }; +}; + +const cronJobStatus = (resource: K8sResourceKind): StatusSummary => { + if (resource.spec?.suspend) { + return { label: 'Suspended', variant: 'warning' }; + } + + const active = (resource.status?.active as unknown[] | undefined)?.length ?? 0; + if (active > 0) { + return { label: `${active} active`, variant: 'info' }; + } + + if (resource.status?.lastSuccessfulTime) { + return { label: 'Scheduled', variant: 'success' }; + } + + return { label: 'No runs yet', variant: 'info' }; +}; + +const loadBalancerReady = (resource: K8sResourceKind): boolean => { + const ingress = resource.status?.loadBalancer?.ingress as unknown[] | undefined; + return (ingress?.length ?? 0) > 0; +}; + +const hpaStatus = (resource: K8sResourceKind): StatusSummary => { + const conditions = resource.status?.conditions as ResourceCondition[] | undefined; + const scalingLimited = conditions?.find( + (condition) => condition.type === 'ScalingLimited' && condition.status === 'True', + ); + if (scalingLimited) { + return { label: scalingLimited.reason || 'ScalingLimited', variant: 'warning' }; + } + + const unableToScale = conditions?.find( + (condition) => condition.type === 'AbleToScale' && condition.status === 'False', + ); + if (unableToScale) { + return { label: unableToScale.reason || 'Unable to scale', variant: 'danger' }; + } + + const current = resource.status?.currentReplicas; + const desired = resource.status?.desiredReplicas; + if (current !== undefined && desired !== undefined) { + return { + label: `${current}/${desired} replicas`, + variant: current === desired ? 'success' : 'warning', + }; + } + + return getGenericResourceStatus(resource); +}; + +export const getResourceStatusSummary = ( + kindName: string, + resource?: K8sResourceKind, +): StatusSummary => { + if (!resource) { + return { label: '—', variant: 'info' }; + } + + switch (kindName) { + case 'Pod': + return podStatus(resource); + case 'Deployment': + case 'StatefulSet': + case 'DaemonSet': + case 'ReplicaSet': + case 'ReplicationController': + case 'DeploymentConfig': + return workloadReplicaStatus(resource); + case 'Job': + return jobStatus(resource); + case 'CronJob': + return cronJobStatus(resource); + case 'PersistentVolumeClaim': + case 'PersistentVolume': { + const phase = resource.status?.phase; + if (typeof phase === 'string') { + return statusFromPhase(phase); + } + return getGenericResourceStatus(resource); + } + case 'Namespace': { + const phase = resource.status?.phase ?? 'Active'; + return statusFromPhase(phase); + } + case 'Service': { + const type = resource.spec?.type ?? 'ClusterIP'; + if (type === 'LoadBalancer') { + return loadBalancerReady(resource) + ? { label: 'LoadBalancer ready', variant: 'success' } + : { label: 'Pending', variant: 'warning' }; + } + // ClusterIP, NodePort, and ExternalName expose type in spec — no runtime status to show. + return { label: '—', variant: 'info' }; + } + case 'Ingress': + return loadBalancerReady(resource) + ? { label: 'Ready', variant: 'success' } + : { label: 'Pending', variant: 'warning' }; + case 'HorizontalPodAutoscaler': + return hpaStatus(resource); + case 'Node': { + const readyCondition = resource.status?.conditions?.find( + (condition: ResourceCondition) => condition.type === 'Ready', + ); + const isReady = readyCondition?.status === 'True'; + return { + label: isReady ? 'Ready' : 'NotReady', + variant: isReady ? 'success' : 'danger', + }; + } + default: + return getGenericResourceStatus(resource); + } +}; + +export const getResourceStatusSummaryForRef = ( + modelKey: string, + models: Record, + resource?: K8sResourceKind, +): StatusSummary => getResourceStatusSummary(getModelKindName(modelKey, models), resource); diff --git a/start-console.sh b/start-console.sh index a75a264f..c7568808 100755 --- a/start-console.sh +++ b/start-console.sh @@ -2,28 +2,103 @@ set -euo pipefail -CONSOLE_TAG=${CONSOLE_TAG:=latest} +# Match the plugin SDK (4.22+). origin-console:latest can lag behind and miss APIs +# such as useUserPreference, which prevents the Lightspeed button from rendering. +CONSOLE_TAG=${CONSOLE_TAG:=4.22} CONSOLE_IMAGE=${CONSOLE_IMAGE:="quay.io/openshift/origin-console:${CONSOLE_TAG}"} CONSOLE_PORT=${CONSOLE_PORT:=9000} OLS_PORT=${OLS_PORT:=8080} +CONSOLE_DEV_SA_NAME=${CONSOLE_DEV_SA_NAME:=lightspeed-console-dev} +CONSOLE_DEV_SA_NAMESPACE=${CONSOLE_DEV_SA_NAMESPACE:=kube-system} + +resolve_api_server() { + if command -v oc >/dev/null 2>&1 && oc whoami >/dev/null 2>&1; then + oc whoami --show-server + return + fi + if command -v kubectl >/dev/null 2>&1; then + kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}' + return + fi + return 1 +} + +resolve_bearer_token() { + local token user + + if command -v oc >/dev/null 2>&1; then + token=$(oc whoami --show-token 2>/dev/null || true) + if [ -n "$token" ]; then + echo "$token" + return + fi + fi + + if ! command -v kubectl >/dev/null 2>&1; then + return 1 + fi + + user=$(kubectl config view --minify -o jsonpath='{.users[0].name}' 2>/dev/null || true) + if [ -n "$user" ]; then + token=$(kubectl config view --raw -o jsonpath="{.users[?(@.name==\"${user}\")].user.token}" 2>/dev/null || true) + if [ -n "$token" ]; then + echo "$token" + return + fi + fi + + # Plain Kubernetes clusters (e.g. kind) often use client certs; mint a dev token instead. + if ! kubectl get serviceaccount "$CONSOLE_DEV_SA_NAME" -n "$CONSOLE_DEV_SA_NAMESPACE" >/dev/null 2>&1; then + kubectl create serviceaccount "$CONSOLE_DEV_SA_NAME" -n "$CONSOLE_DEV_SA_NAMESPACE" >/dev/null + fi + if ! kubectl get clusterrolebinding "$CONSOLE_DEV_SA_NAME" >/dev/null 2>&1; then + kubectl create clusterrolebinding "$CONSOLE_DEV_SA_NAME" \ + --clusterrole=cluster-admin \ + --serviceaccount="${CONSOLE_DEV_SA_NAMESPACE}:${CONSOLE_DEV_SA_NAME}" >/dev/null + fi + kubectl -n "$CONSOLE_DEV_SA_NAMESPACE" create token "$CONSOLE_DEV_SA_NAME" --duration=24h +} echo "Starting local OpenShift console..." +if ! command -v oc >/dev/null 2>&1 && ! command -v kubectl >/dev/null 2>&1; then + echo "error: neither oc nor kubectl found in PATH." >&2 + exit 1 +fi + +if command -v oc >/dev/null 2>&1 && ! oc whoami >/dev/null 2>&1; then + echo "error: not logged in to a cluster. Run 'oc login' (or point KUBECONFIG at a running cluster) and retry." >&2 + exit 1 +fi + +if command -v kubectl >/dev/null 2>&1 && ! kubectl cluster-info >/dev/null 2>&1; then + echo "error: cannot reach the Kubernetes API. Is your cluster running?" >&2 + echo " Try: kubectl cluster-info" >&2 + exit 1 +fi + +BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT=$(resolve_api_server) +BRIDGE_K8S_AUTH_BEARER_TOKEN=$(resolve_bearer_token || true) + +if [ -z "$BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT" ] || [ -z "$BRIDGE_K8S_AUTH_BEARER_TOKEN" ]; then + echo "error: could not read API server or bearer token." >&2 + echo " server: ${BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT:-}" >&2 + echo " OpenShift: run 'oc whoami --show-token'." >&2 + echo " kind/k8s: ensure 'kubectl create token' works, or log in with a kubeconfig that includes a user token." >&2 + exit 1 +fi + BRIDGE_USER_AUTH="disabled" BRIDGE_K8S_MODE="off-cluster" BRIDGE_K8S_AUTH="bearer-token" BRIDGE_K8S_MODE_OFF_CLUSTER_SKIP_VERIFY_TLS=true -BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT=$(oc whoami --show-server) + +BRIDGE_USER_SETTINGS_LOCATION="localstorage" + # The monitoring operator is not always installed (e.g. for local OpenShift). Tolerate missing config maps. set +e BRIDGE_K8S_MODE_OFF_CLUSTER_THANOS=$(oc -n openshift-config-managed get configmap monitoring-shared-config -o jsonpath='{.data.thanosPublicURL}' 2>/dev/null) BRIDGE_K8S_MODE_OFF_CLUSTER_ALERTMANAGER=$(oc -n openshift-config-managed get configmap monitoring-shared-config -o jsonpath='{.data.alertmanagerPublicURL}' 2>/dev/null) -set -e -BRIDGE_K8S_AUTH_BEARER_TOKEN=$(oc whoami --show-token 2>/dev/null) -BRIDGE_USER_SETTINGS_LOCATION="localstorage" - -# Don't fail if the cluster doesn't have gitops. -set +e GITOPS_HOSTNAME=$(oc -n openshift-gitops get route cluster -o jsonpath='{.spec.host}' 2>/dev/null) set -e if [ -n "$GITOPS_HOSTNAME" ]; then diff --git a/unit-tests/consoleNavigation.test.ts b/unit-tests/consoleNavigation.test.ts new file mode 100644 index 00000000..99bddc3c --- /dev/null +++ b/unit-tests/consoleNavigation.test.ts @@ -0,0 +1,12 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { navigateToConsolePath } from '../src/consoleNavigation'; + +describe('navigateToConsolePath', () => { + it('ignores empty paths', () => { + navigateToConsolePath(''); + navigateToConsolePath('#'); + strictEqual(true, true); + }); +}); diff --git a/unit-tests/crStatus.test.ts b/unit-tests/crStatus.test.ts new file mode 100644 index 00000000..424f24ca --- /dev/null +++ b/unit-tests/crStatus.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { getGenericResourceStatus } from '../src/crStatus'; + +const csvStatus = { + phase: 'Succeeded', + reason: 'InstallSucceeded', + message: 'install strategy completed with no errors', + conditions: [ + { + lastTransitionTime: '2026-06-30T08:47:39Z', + message: 'requirements not yet checked', + phase: 'Pending', + reason: 'RequirementsUnknown', + }, + { + lastTransitionTime: '2026-06-30T09:11:07Z', + message: 'install strategy completed with no errors', + phase: 'Succeeded', + reason: 'InstallSucceeded', + }, + ], +}; + +describe('getGenericResourceStatus', () => { + it('prefers top-level phase over historical conditions', () => { + const summary = getGenericResourceStatus({ status: csvStatus }); + strictEqual(summary.label, 'Succeeded (InstallSucceeded)'); + strictEqual(summary.variant, 'success'); + }); + + it('uses standard Ready conditions for kubebuilder CRDs', () => { + const summary = getGenericResourceStatus({ + status: { + conditions: [{ type: 'Ready', status: 'True', reason: 'Ready' }], + }, + }); + strictEqual(summary.label, 'Ready'); + strictEqual(summary.variant, 'success'); + }); + + it('uses latest phase condition when only OLM-style conditions exist', () => { + const summary = getGenericResourceStatus({ + status: { + conditions: [ + { + lastTransitionTime: '2026-06-30T08:47:39Z', + phase: 'Pending', + reason: 'RequirementsUnknown', + }, + { + lastTransitionTime: '2026-06-30T09:06:44Z', + phase: 'Failed', + reason: 'ComponentUnhealthy', + }, + ], + }, + }); + strictEqual(summary.label, 'Failed (ComponentUnhealthy)'); + strictEqual(summary.variant, 'danger'); + }); +}); diff --git a/unit-tests/fixtures/k8sModels.ts b/unit-tests/fixtures/k8sModels.ts new file mode 100644 index 00000000..05438777 --- /dev/null +++ b/unit-tests/fixtures/k8sModels.ts @@ -0,0 +1,75 @@ +import { K8sModelRef } from '../../src/pageContext'; + +export const testK8sModels: Record = { + ConfigMap: { kind: 'ConfigMap', plural: 'configmaps', namespaced: true }, + CronJob: { kind: 'CronJob', plural: 'cronjobs', namespaced: true }, + DaemonSet: { kind: 'DaemonSet', plural: 'daemonsets', namespaced: true }, + Deployment: { kind: 'Deployment', plural: 'deployments', namespaced: true }, + DeploymentConfig: { kind: 'DeploymentConfig', plural: 'deploymentconfigs', namespaced: true }, + HorizontalPodAutoscaler: { + kind: 'HorizontalPodAutoscaler', + plural: 'horizontalpodautoscalers', + namespaced: true, + }, + Ingress: { kind: 'Ingress', plural: 'ingresses', namespaced: true }, + Job: { kind: 'Job', plural: 'jobs', namespaced: true }, + Node: { kind: 'Node', plural: 'nodes', namespaced: false }, + Namespace: { kind: 'Namespace', plural: 'namespaces', namespaced: false }, + Pod: { kind: 'Pod', plural: 'pods', namespaced: true }, + ReplicaSet: { kind: 'ReplicaSet', plural: 'replicasets', namespaced: true }, + ReplicationController: { + kind: 'ReplicationController', + plural: 'replicationcontrollers', + namespaced: true, + }, + PersistentVolume: { kind: 'PersistentVolume', plural: 'persistentvolumes', namespaced: false }, + PersistentVolumeClaim: { + kind: 'PersistentVolumeClaim', + plural: 'persistentvolumeclaims', + namespaced: true, + }, + ServiceAccount: { kind: 'ServiceAccount', plural: 'serviceaccounts', namespaced: true }, + 'config.openshift.io~v1~ClusterVersion': { + kind: 'ClusterVersion', + plural: 'clusterversions', + namespaced: false, + }, + 'machineconfiguration.openshift.io~v1~MachineConfigPool': { + kind: 'MachineConfigPool', + plural: 'machineconfigpools', + namespaced: false, + }, + 'operators.coreos.com~v1alpha1~Subscription': { + kind: 'Subscription', + plural: 'subscriptions', + namespaced: true, + }, + 'operator.openshift.io~v1~IngressController': { + kind: 'IngressController', + plural: 'ingresscontrollers', + namespaced: false, + }, + 'route.openshift.io~v1~Route': { + kind: 'Route', + plural: 'routes', + namespaced: true, + }, + Secret: { kind: 'Secret', plural: 'secrets', namespaced: true }, + Service: { kind: 'Service', plural: 'services', namespaced: true }, + StatefulSet: { kind: 'StatefulSet', plural: 'statefulsets', namespaced: true }, + 'kubevirt.io~v1~VirtualMachine': { + kind: 'VirtualMachine', + plural: 'virtualmachines', + namespaced: true, + }, + 'flows.netobserv.io~v1beta2~FlowCollector': { + kind: 'FlowCollector', + plural: 'flowcollectors', + namespaced: false, + }, + 'flows.netobserv.io~v1beta2~FlowCollectorSlice': { + kind: 'FlowCollectorSlice', + plural: 'flowcollectorslices', + namespaced: true, + }, +}; diff --git a/unit-tests/linkedResourceStatusDisplay.test.ts b/unit-tests/linkedResourceStatusDisplay.test.ts new file mode 100644 index 00000000..a8fdb521 --- /dev/null +++ b/unit-tests/linkedResourceStatusDisplay.test.ts @@ -0,0 +1,108 @@ +import { describe, it } from 'node:test'; +import { deepStrictEqual, strictEqual } from 'node:assert'; + +import { + getLinkedResourceStatusDisplay, + toConsoleStatusKey, + usesConsoleStatusIcon, +} from '../src/linkedResourceStatusDisplay'; + +describe('usesConsoleStatusIcon', () => { + it('returns true for built-in Pod', () => { + strictEqual(usesConsoleStatusIcon('Pod', 'Pod'), true); + }); + + it('returns false for CRD model keys', () => { + strictEqual( + usesConsoleStatusIcon('FlowCollector', 'flows.netobserv.io~v1beta2~FlowCollector'), + false, + ); + }); + + it('returns false for HPA', () => { + strictEqual(usesConsoleStatusIcon('HorizontalPodAutoscaler', 'HorizontalPodAutoscaler'), false); + }); +}); + +describe('toConsoleStatusKey', () => { + it('maps pod phases with icons', () => { + deepStrictEqual(toConsoleStatusKey({ label: 'Running', variant: 'success' }, 'Pod'), { + status: 'Running', + useIcon: true, + }); + deepStrictEqual(toConsoleStatusKey({ label: 'Failed', variant: 'danger' }, 'Pod'), { + status: 'Failed', + useIcon: true, + }); + }); + + it('maps container reasons with icons', () => { + deepStrictEqual(toConsoleStatusKey({ label: 'CrashLoopBackOff', variant: 'danger' }, 'Pod'), { + status: 'CrashLoopBackOff', + useIcon: true, + }); + }); + + it('maps replica counts to console workload icons', () => { + deepStrictEqual(toConsoleStatusKey({ label: '1/1 ready', variant: 'success' }, 'Deployment'), { + status: 'Running', + useIcon: true, + }); + deepStrictEqual(toConsoleStatusKey({ label: '1/2 ready', variant: 'warning' }, 'Deployment'), { + status: 'Warning', + useIcon: true, + }); + deepStrictEqual(toConsoleStatusKey({ label: '0/2 ready', variant: 'danger' }, 'Deployment'), { + status: 'Failed', + useIcon: true, + }); + }); + + it('maps node readiness', () => { + deepStrictEqual(toConsoleStatusKey({ label: 'NotReady', variant: 'danger' }, 'Node'), { + status: 'Not Ready', + useIcon: true, + }); + }); + + it('falls back to text for unmapped operator reasons', () => { + deepStrictEqual( + toConsoleStatusKey( + { label: 'ScalingLimited', variant: 'warning' }, + 'HorizontalPodAutoscaler', + ), + { status: 'ScalingLimited', useIcon: false }, + ); + }); +}); + +describe('getLinkedResourceStatusDisplay', () => { + it('returns icon mode for pods', () => { + deepStrictEqual( + getLinkedResourceStatusDisplay({ label: 'Running', variant: 'success' }, 'Pod', 'Pod'), + { mode: 'icon', status: 'Running', title: 'Running' }, + ); + }); + + it('returns text mode for operator CRDs', () => { + strictEqual( + getLinkedResourceStatusDisplay( + { label: 'Ready', variant: 'success' }, + 'FlowCollector', + 'flows.netobserv.io~v1beta2~FlowCollector', + ).mode, + 'text', + ); + }); + + it('returns text mode for unmapped custom labels on built-in kinds', () => { + strictEqual( + getLinkedResourceStatusDisplay( + { label: 'ScalingLimited', variant: 'warning' }, + 'HorizontalPodAutoscaler', + 'HorizontalPodAutoscaler', + ).mode, + 'text', + ); + }); +}); diff --git a/unit-tests/linkedResourceText.test.ts b/unit-tests/linkedResourceText.test.ts new file mode 100644 index 00000000..2835476a --- /dev/null +++ b/unit-tests/linkedResourceText.test.ts @@ -0,0 +1,137 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { injectResourceLinksInMarkdown } from '../src/linkedResourceText'; +import { testK8sModels } from './fixtures/k8sModels'; + +describe('injectResourceLinksInMarkdown', () => { + it('links pod names on Name: lines without touching age values', () => { + const response = `Here are pods in namespace default: + +Name: mock-pod-a +Ready: 1/1 +Status: Running +Restarts: 0 +Age: 5m + +Name: mock-pod-b +Ready: 1/1 +Status: Running +Restarts: 0 +Age: 5m`; + + const linked = injectResourceLinksInMarkdown( + response, + [ + { kind: 'Pod', name: 'mock-pod-a', namespace: 'default' }, + { kind: 'Pod', name: 'mock-pod-b', namespace: 'default' }, + ], + testK8sModels, + ); + + strictEqual( + linked, + `Here are pods in namespace default: + +Name: [mock-pod-a](/k8s/ns/default/pods/mock-pod-a) +Ready: 1/1 +Status: Running +Restarts: 0 +Age: 5m + +Name: [mock-pod-b](/k8s/ns/default/pods/mock-pod-b) +Ready: 1/1 +Status: Running +Restarts: 0 +Age: 5m`, + ); + }); + + it('wraps bold resource mentions in prose', () => { + const response = + 'The lightspeed-postgres-server pod is healthy. Check **lightspeed-postgres-server** again.'; + const linked = injectResourceLinksInMarkdown( + response, + [ + { + kind: 'Pod', + name: 'lightspeed-postgres-server', + namespace: 'openshift-lightspeed', + }, + ], + testK8sModels, + ); + + strictEqual( + linked, + 'The lightspeed-postgres-server pod is healthy. Check [lightspeed-postgres-server](/k8s/ns/openshift-lightspeed/pods/lightspeed-postgres-server) again.', + ); + }); + + it('links a standalone resource name line in legacy listings', () => { + const response = `Here are the pods in the "openshift-lightspeed" namespace: + +lightspeed-app-server-abc +2/2 +Running +0 +72m`; + + const linked = injectResourceLinksInMarkdown( + response, + [ + { + kind: 'Pod', + name: 'lightspeed-app-server-abc', + namespace: 'openshift-lightspeed', + }, + ], + testK8sModels, + ); + + strictEqual( + linked, + `Here are the pods in the "openshift-lightspeed" namespace: + +[lightspeed-app-server-abc](/k8s/ns/openshift-lightspeed/pods/lightspeed-app-server-abc) +2/2 +Running +0 +72m`, + ); + }); + + it('does not link resource names inside inline code', () => { + const response = 'Use `lightspeed-app-server-abc` as the pod name.'; + const linked = injectResourceLinksInMarkdown( + response, + [ + { + kind: 'Pod', + name: 'lightspeed-app-server-abc', + namespace: 'openshift-lightspeed', + }, + ], + testK8sModels, + ); + + strictEqual(linked, response); + }); + + it('does not replace shorter names inside longer resource names', () => { + const response = 'Name: lightspeed-app-server-abc-extra'; + const linked = injectResourceLinksInMarkdown( + response, + [ + { + kind: 'Pod', + name: 'lightspeed-app-server-abc', + namespace: 'openshift-lightspeed', + }, + ], + testK8sModels, + ); + + strictEqual(linked, response); + }); +}); diff --git a/unit-tests/linkedResourceWatch.test.ts b/unit-tests/linkedResourceWatch.test.ts new file mode 100644 index 00000000..a55f2d8b --- /dev/null +++ b/unit-tests/linkedResourceWatch.test.ts @@ -0,0 +1,93 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { + buildResourceWatchProps, + matchesResourceRef, + shouldShowLinkedResourceStatus, +} from '../src/linkedResourceWatch'; +import { testK8sModels } from './fixtures/k8sModels'; + +describe('buildResourceWatchProps', () => { + it('watches Namespace for project refs', () => { + const watchProps = buildResourceWatchProps( + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, + ); + strictEqual(watchProps?.kind, 'Namespace'); + strictEqual(watchProps?.name, 'payments'); + strictEqual(watchProps?.namespace, undefined); + }); + + it('watches namespaced resources in their namespace', () => { + const watchProps = buildResourceWatchProps( + { kind: 'Pod', name: 'api-1', namespace: 'payments' }, + testK8sModels, + ); + strictEqual(watchProps?.kind, 'Pod'); + strictEqual(watchProps?.name, 'api-1'); + strictEqual(watchProps?.namespace, 'payments'); + }); +}); + +describe('matchesResourceRef', () => { + it('rejects stale watch data for a different resource name', () => { + strictEqual( + matchesResourceRef( + { metadata: { name: 'default' } }, + { kind: 'Namespace', name: 'kube-system' }, + ), + false, + ); + }); + + it('accepts matching namespace resources', () => { + strictEqual( + matchesResourceRef( + { metadata: { name: 'payments' } }, + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + ), + true, + ); + }); +}); + +describe('shouldShowLinkedResourceStatus', () => { + it('skips namespace, project, and data-only refs', () => { + strictEqual( + shouldShowLinkedResourceStatus( + { kind: 'Namespace', name: 'default', useProjectRoute: true }, + testK8sModels, + ), + false, + ); + strictEqual( + shouldShowLinkedResourceStatus({ kind: 'Project', name: 'payments' }, testK8sModels), + false, + ); + strictEqual( + shouldShowLinkedResourceStatus( + { kind: 'ConfigMap', name: 'app-config', namespace: 'default' }, + testK8sModels, + ), + false, + ); + strictEqual( + shouldShowLinkedResourceStatus( + { kind: 'Secret', name: 'tls', namespace: 'default' }, + testK8sModels, + ), + false, + ); + }); + + it('shows status for other resource kinds', () => { + strictEqual( + shouldShowLinkedResourceStatus( + { kind: 'Pod', name: 'api-1', namespace: 'payments' }, + testK8sModels, + ), + true, + ); + }); +}); diff --git a/unit-tests/linkedResources.test.ts b/unit-tests/linkedResources.test.ts new file mode 100644 index 00000000..bbba8d01 --- /dev/null +++ b/unit-tests/linkedResources.test.ts @@ -0,0 +1,416 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { + extractLinkedResources, + getInlineLinkedResources, + getLinkedResourceOverflow, + isLinkableResource, + isPlausibleResourceRef, + MAX_LINKED_RESOURCES, + MAX_LINKED_RESOURCES_POD_LIST, + prioritizeLinkedResources, +} from '../src/linkedResources'; +import { Tool } from '../src/types'; +import { testK8sModels } from './fixtures/k8sModels'; + +describe('isLinkableResource', () => { + it('accepts cluster-scoped CRDs such as FlowCollector', () => { + strictEqual( + isLinkableResource({ kind: 'FlowCollector', name: 'cluster' }, testK8sModels), + true, + ); + }); + + it('accepts namespaced CRDs with a namespace', () => { + strictEqual( + isLinkableResource( + { + kind: 'FlowCollectorSlice', + name: 'tenant-a', + namespace: 'netobserv', + }, + testK8sModels, + ), + true, + ); + }); + + it('rejects list placeholders where the name equals the kind', () => { + strictEqual( + isPlausibleResourceRef( + { kind: 'Deployment', name: 'Deployment', namespace: 'payments' }, + testK8sModels, + ), + false, + ); + }); +}); + +describe('prioritizeLinkedResources', () => { + it('moves the pod named in the response text to the front', () => { + const refs = prioritizeLinkedResources( + [ + { + kind: 'Pod', + name: 'lightspeed-app-server-6975d49c5c-5rdlk', + namespace: 'openshift-lightspeed', + }, + { + kind: 'Pod', + name: 'lightspeed-console-plugin-588df96978-wcjqj', + namespace: 'openshift-lightspeed', + }, + ], + 'Name: lightspeed-console-plugin-588df96978-wcjqj\nStatus: Running', + ); + + strictEqual(refs[0].name, 'lightspeed-console-plugin-588df96978-wcjqj'); + }); + + it('prioritizes every pod named in the response before unmentioned pods', () => { + const refs = prioritizeLinkedResources( + [ + { kind: 'Pod', name: 'pod-a', namespace: 'ns' }, + { kind: 'Pod', name: 'pod-b', namespace: 'ns' }, + { kind: 'Pod', name: 'pod-c', namespace: 'ns' }, + { kind: 'Pod', name: 'pod-d', namespace: 'ns' }, + ], + 'Name: pod-d\nName: pod-b', + ); + + strictEqual(refs[0].name, 'pod-d'); + strictEqual(refs[1].name, 'pod-b'); + strictEqual(refs[2].name, 'pod-a'); + strictEqual(refs[3].name, 'pod-c'); + }); +}); + +describe('extractLinkedResources', () => { + it('returns watchable resources from tools', () => { + const tools: Record = { + t1: { + args: { name: 'payments-api', namespace: 'payments' }, + content: '', + name: 'pods_get', + status: 'success', + }, + t2: { + args: { + kind: 'Deployment', + name: 'reporting-service', + namespace: 'shared-services', + }, + content: '', + name: 'resources_get', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[1].kind, 'Deployment'); + }); + + it('includes FlowCollector from resources_get', () => { + const tools: Record = { + t1: { + args: { kind: 'FlowCollector', name: 'cluster' }, + content: '', + name: 'resources_get', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'flows.netobserv.io~v1beta2~FlowCollector'); + strictEqual(refs[0].name, 'cluster'); + }); + + it('caps the number of live widgets', () => { + const tools: Record = Object.fromEntries( + Array.from({ length: MAX_LINKED_RESOURCES + 2 }, (_, index) => [ + `t${index}`, + { + args: { name: `pod-${index}`, namespace: 'payments' }, + content: '', + name: 'pods_get', + status: 'success', + } satisfies Tool, + ]), + ); + + strictEqual( + extractLinkedResources(tools, undefined, testK8sModels).length, + MAX_LINKED_RESOURCES, + ); + }); + + it('skips resources without a namespace except cluster-scoped kinds', () => { + const tools: Record = { + t1: { + args: { name: 'payments-api' }, + content: '', + name: 'pods_get', + status: 'success', + }, + t2: { + args: { name: 'worker-1' }, + content: '', + name: 'nodes_top', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Node'); + }); + + it('includes pods from pods_list_in_namespace table content', () => { + const tableContent = `openshift-lightspeed v1 Pod lightspeed-app-server-abc 2/2 Running 0 1m 10.0.0.1 node `; + const refs = extractLinkedResources( + { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableContent, + name: 'pods_list_in_namespace', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].name, 'lightspeed-app-server-abc'); + }); + + it('includes every pod from a namespace pod list up to the pod-list cap', () => { + const tableRows = Array.from( + { length: 4 }, + (_, index) => + `openshift-lightspeed v1 Pod lightspeed-pod-${index} 1/1 Running 0 1m 10.0.0.${index} node `, + ).join('\n'); + const tools: Record = { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableRows, + name: 'pods_list_in_namespace', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[3].name, 'lightspeed-pod-3'); + }); + + it('includes pods from events_list involved objects', () => { + const eventsContent = `# The following events (YAML format) were found: +- InvolvedObject: + Kind: Pod + Name: redhat-operators-2mhcl + Namespace: openshift-redhat-operators + Type: Warning`; + + const refs = extractLinkedResources( + { + t1: { args: {}, content: '', name: 'pods_list', status: 'success' }, + t2: { args: {}, content: eventsContent, name: 'events_list', status: 'success' }, + }, + 'Warning related to the redhat-operators-2mhcl pod.', + testK8sModels, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'redhat-operators-2mhcl'); + strictEqual(refs[0].namespace, 'openshift-redhat-operators'); + }); + + it('includes nodes from nodes_top table content', () => { + const tableContent = `NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) +ip-10-0-110-168.us-west-1.compute.internal 117m 3% 3233Mi 22% +ip-10-0-19-247.us-west-1.compute.internal 710m 20% 10120Mi 69% +ip-10-0-52-226.us-west-1.compute.internal 148m 4% 3019Mi 20% +ip-10-0-82-49.us-west-1.compute.internal 182m 5% 2968Mi 20%`; + const tools: Record = { + t1: { + args: {}, + content: tableContent, + name: 'nodes_top', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'ip-10-0-110-168.us-west-1.compute.internal'); + }); + + it('includes nodes from resources_list table content', () => { + const tableContent = `APIVERSION KIND NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +v1 Node ip-10-0-110-168.us-west-1.compute.internal Ready worker 3h17m Red Hat Enterprise Linux CoreOS 10.0.110.168 +v1 Node ip-10-0-19-247.us-west-1.compute.internal Ready control-plane,master 3h35m Red Hat Enterprise Linux CoreOS 10.0.19.247 +v1 Node ip-10-0-52-226.us-west-1.compute.internal Ready worker 3h16m Red Hat Enterprise Linux CoreOS 10.0.52.226 +v1 Node ip-10-0-82-49.us-west-1.compute.internal Ready worker 3h17m Red Hat Enterprise Linux CoreOS 10.0.82.49`; + const tools: Record = { + t1: { + args: { apiVersion: 'v1', kind: 'Node' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'ip-10-0-110-168.us-west-1.compute.internal'); + }); + + it('includes namespaces from namespaces_list table content', () => { + const tableContent = `APIVERSION KIND NAME STATUS AGE LABELS +v1 Namespace default Active 3h38m kubernetes.io/metadata.name=default +v1 Namespace kube-system Active 3h38m kubernetes.io/metadata.name=kube-system +v1 Namespace openshift Active 3h33m kubernetes.io/metadata.name=openshift +v1 Namespace openshift-lightspeed Active 169m pod-security.kubernetes.io/enforce=restricted`; + const tools: Record = { + t1: { + args: {}, + content: tableContent, + name: 'namespaces_list', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + }); + + it('includes namespaces from LLM vertical listing when namespaces_list ran', () => { + const tools: Record = { + t1: { + args: {}, + content: '', + name: 'namespaces_list', + status: 'success', + }, + }; + const response = `Here are the OpenShift namespaces in the current cluster: + +default +Active +3h38m +openshift-lightspeed +Active +169m`; + + const refs = extractLinkedResources(tools, response, testK8sModels); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + strictEqual(refs[1].name, 'openshift-lightspeed'); + }); + + it('includes deployments from resources_list table content', () => { + const tableContent = `NAMESPACE APIVERSION KIND NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +payments apps/v1 Deployment payments-api 2/2 2 2 3h10m payments-api registry/payments:latest app=payments +payments apps/v1 Deployment reporting-service 1/1 1 1 2h5m reporting-service registry/reporting:latest app=reporting`; + const tools: Record = { + t1: { + args: { apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'payments' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }; + + const refs = extractLinkedResources(tools, undefined, testK8sModels); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Deployment'); + strictEqual(refs[0].name, 'payments-api'); + }); + + it('prioritizes pods mentioned in the response before applying the default cap', () => { + const tableRows = [ + 'openshift-lightspeed v1 Pod lightspeed-pod-0 1/1 Running 0 1m 10.0.0.0 node ', + 'openshift-lightspeed v1 Pod lightspeed-pod-1 1/1 Running 0 1m 10.0.0.1 node ', + 'openshift-lightspeed v1 Pod lightspeed-pod-2 1/1 Running 0 1m 10.0.0.2 node ', + 'openshift-lightspeed v1 Pod lightspeed-postgres-server-77dd7fb495-r6gsf 1/1 Running 1 3m 10.130.0.20 node ', + ].join('\n'); + const tools: Record = { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableRows, + name: 'pods_list_in_namespace', + status: 'success', + }, + }; + const responseText = `Pods in openshift-lightspeed are healthy. + +Name: lightspeed-postgres-server-77dd7fb495-r6gsf +Ready: 1/1 +Restarts: 1`; + + const refs = extractLinkedResources(tools, responseText, testK8sModels); + strictEqual(refs.length, 4); + strictEqual(refs[0].name, 'lightspeed-postgres-server-77dd7fb495-r6gsf'); + }); + + it('reports overflow when more resources exist than the live widget cap', () => { + const tableRows = Array.from( + { length: MAX_LINKED_RESOURCES_POD_LIST + 2 }, + (_, index) => + `openshift-lightspeed v1 Pod lightspeed-pod-${index} 1/1 Running 0 1m 10.0.0.${index} node `, + ).join('\n'); + const tools: Record = { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableRows, + name: 'pods_list_in_namespace', + status: 'success', + }, + }; + + const overflow = getLinkedResourceOverflow(tools, undefined, testK8sModels); + strictEqual(overflow.shown, MAX_LINKED_RESOURCES_POD_LIST); + strictEqual(overflow.total, MAX_LINKED_RESOURCES_POD_LIST + 2); + }); +}); + +describe('getInlineLinkedResources', () => { + it('returns only linkable resources whose names appear in the response text', () => { + const tools: Record = { + t1: { + args: { name: 'payments-api', namespace: 'payments' }, + content: '', + name: 'pods_get', + status: 'success', + }, + t2: { + args: { + kind: 'Deployment', + name: 'reporting-service', + namespace: 'shared-services', + }, + content: '', + name: 'resources_get', + status: 'success', + }, + }; + + const refs = getInlineLinkedResources( + tools, + 'The payments-api pod is healthy but the deployment was not discussed.', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].name, 'payments-api'); + }); +}); diff --git a/unit-tests/pageContext.test.ts b/unit-tests/pageContext.test.ts index dc6f580a..88447e6a 100644 --- a/unit-tests/pageContext.test.ts +++ b/unit-tests/pageContext.test.ts @@ -1,7 +1,18 @@ import { describe, it } from 'node:test'; import { strictEqual } from 'node:assert'; -import { buildPageContext, resolveModelKey } from '../src/pageContext'; +import { + buildPageContext, + buildResourceConsolePath, + getResourceIconKind, + isClusterScopedRef, + isNamespacedRef, + resolveKindToModelKey, + resolveModelKey, + resolveRefModelKey, + resolveRefModelKeyOrKind, +} from '../src/pageContext'; +import { testK8sModels } from './fixtures/k8sModels'; describe('buildPageContext', () => { it('returns undefined when kind is undefined', () => { @@ -44,51 +55,229 @@ describe('buildPageContext', () => { }); }); -const models = { - Pod: { kind: 'Pod', plural: 'pods' }, - Deployment: { kind: 'Deployment', plural: 'deployments' }, - Node: { kind: 'Node', plural: 'nodes' }, - Secret: { kind: 'Secret', plural: 'secrets' }, - 'kubevirt.io~v1~VirtualMachine': { kind: 'VirtualMachine', plural: 'virtualmachines' }, -}; - describe('resolveModelKey', () => { it('resolves a direct model key', () => { - strictEqual(resolveModelKey('Pod', models), 'Pod'); + strictEqual(resolveModelKey('Pod', testK8sModels), 'Pod'); }); it('resolves a CRD model key with group~version~kind', () => { strictEqual( - resolveModelKey('kubevirt.io~v1~VirtualMachine', models), + resolveModelKey('kubevirt.io~v1~VirtualMachine', testK8sModels), 'kubevirt.io~v1~VirtualMachine', ); }); it('resolves a plural name to the model key', () => { - strictEqual(resolveModelKey('pods', models), 'Pod'); + strictEqual(resolveModelKey('pods', testK8sModels), 'Pod'); }); it('resolves a plural CRD name to the model key', () => { - strictEqual(resolveModelKey('virtualmachines', models), 'kubevirt.io~v1~VirtualMachine'); + strictEqual(resolveModelKey('virtualmachines', testK8sModels), 'kubevirt.io~v1~VirtualMachine'); }); it('resolves core~v1~Kind by extracting the kind portion', () => { - strictEqual(resolveModelKey('core~v1~Pod', models), 'Pod'); + strictEqual(resolveModelKey('core~v1~Pod', testK8sModels), 'Pod'); }); it('resolves core~v1~Deployment by extracting the kind portion', () => { - strictEqual(resolveModelKey('core~v1~Deployment', models), 'Deployment'); + strictEqual(resolveModelKey('core~v1~Deployment', testK8sModels), 'Deployment'); }); it('returns undefined for an unknown key', () => { - strictEqual(resolveModelKey('UnknownResource', models), undefined); + strictEqual(resolveModelKey('UnknownResource', testK8sModels), undefined); }); it('returns undefined for an unknown group~version~kind', () => { - strictEqual(resolveModelKey('fake.io~v1~Nothing', models), undefined); + strictEqual(resolveModelKey('fake.io~v1~Nothing', testK8sModels), undefined); }); it('returns undefined for an empty string', () => { - strictEqual(resolveModelKey('', models), undefined); + strictEqual(resolveModelKey('', testK8sModels), undefined); + }); +}); + +describe('resolveKindToModelKey', () => { + it('resolves a direct model key', () => { + strictEqual(resolveKindToModelKey('Pod', testK8sModels), 'Pod'); + }); + + it('resolves a Kubernetes kind name to the model key', () => { + strictEqual( + resolveKindToModelKey('VirtualMachine', testK8sModels), + 'kubevirt.io~v1~VirtualMachine', + ); + }); + + it('resolves a group~version~kind reference', () => { + strictEqual( + resolveKindToModelKey('kubevirt.io~v1~VirtualMachine', testK8sModels), + 'kubevirt.io~v1~VirtualMachine', + ); + }); + + it('returns undefined for unknown kinds', () => { + strictEqual(resolveKindToModelKey('NotARealKind', testK8sModels), undefined); + }); + + it('resolves Project kind to Namespace model key', () => { + strictEqual(resolveKindToModelKey('Project', testK8sModels), 'Namespace'); + }); +}); + +describe('getResourceIconKind', () => { + it('returns model key for built-in kinds', () => { + strictEqual( + getResourceIconKind({ kind: 'Pod', name: 'api-1', namespace: 'default' }, testK8sModels), + 'Pod', + ); + }); + + it('returns model key for CRD refs', () => { + strictEqual( + getResourceIconKind({ kind: 'ClusterVersion', name: 'version' }, testK8sModels), + 'config.openshift.io~v1~ClusterVersion', + ); + }); + + it('uses Project icon for project routes', () => { + strictEqual( + getResourceIconKind( + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, + ), + 'Project', + ); + }); +}); + +describe('resolveRefModelKey', () => { + it('resolves kind names and model keys from a resource ref', () => { + strictEqual( + resolveRefModelKey({ kind: 'Pod', name: 'api-1', namespace: 'default' }, testK8sModels), + 'Pod', + ); + strictEqual( + resolveRefModelKey({ kind: 'ClusterVersion', name: 'version' }, testK8sModels), + 'config.openshift.io~v1~ClusterVersion', + ); + strictEqual(resolveRefModelKey({ kind: 'NotARealKind', name: 'x' }, testK8sModels), undefined); + }); + + it('resolves project-route refs to the Namespace model', () => { + strictEqual( + resolveRefModelKey( + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, + ), + 'Namespace', + ); + }); +}); + +describe('resolveRefModelKeyOrKind', () => { + it('falls back to ref.kind when the model is unknown', () => { + strictEqual( + resolveRefModelKeyOrKind({ kind: 'CustomKind', name: 'example' }, testK8sModels), + 'CustomKind', + ); + }); +}); + +describe('resource scope helpers', () => { + it('detects cluster-scoped refs', () => { + strictEqual(isClusterScopedRef({ kind: 'Node', name: 'worker-1' }, testK8sModels), true); + strictEqual( + isClusterScopedRef( + { kind: 'flows.netobserv.io~v1beta2~FlowCollector', name: 'cluster' }, + testK8sModels, + ), + true, + ); + strictEqual( + isClusterScopedRef( + { kind: 'Deployment', name: 'payments-api', namespace: 'payments' }, + testK8sModels, + ), + false, + ); + }); + + it('detects namespaced refs', () => { + strictEqual( + isNamespacedRef( + { kind: 'Deployment', name: 'payments-api', namespace: 'payments' }, + testK8sModels, + ), + true, + ); + strictEqual(isNamespacedRef({ kind: 'Node', name: 'worker-1' }, testK8sModels), false); + }); +}); + +describe('buildResourceConsolePath', () => { + it('builds namespaced pod path', () => { + strictEqual( + buildResourceConsolePath( + { kind: 'Pod', name: 'payments-api', namespace: 'payments' }, + testK8sModels, + ), + '/k8s/ns/payments/pods/payments-api', + ); + }); + + it('builds cluster node path', () => { + strictEqual( + buildResourceConsolePath({ kind: 'Node', name: 'worker-1' }, testK8sModels), + '/k8s/cluster/nodes/worker-1', + ); + }); + + it('builds deployment path', () => { + strictEqual( + buildResourceConsolePath( + { + kind: 'Deployment', + name: 'reporting-service', + namespace: 'shared-services', + }, + testK8sModels, + ), + '/k8s/ns/shared-services/deployments/reporting-service', + ); + }); + + it('builds CRD detail path using the model key', () => { + strictEqual( + buildResourceConsolePath( + { + kind: 'VirtualMachine', + name: 'my-vm', + namespace: 'default', + }, + testK8sModels, + ), + '/k8s/ns/default/kubevirt.io~v1~VirtualMachine/my-vm', + ); + }); + + it('returns null without namespace for namespaced kinds', () => { + strictEqual(buildResourceConsolePath({ kind: 'Pod', name: 'test' }, testK8sModels), null); + }); + + it('builds OpenShift project path when useProjectRoute is set', () => { + strictEqual( + buildResourceConsolePath( + { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, + ), + '/k8s/cluster/projects/payments', + ); + }); + + it('builds namespace admin path by default', () => { + strictEqual( + buildResourceConsolePath({ kind: 'Namespace', name: 'payments' }, testK8sModels), + '/k8s/cluster/namespaces/payments', + ); }); }); diff --git a/unit-tests/resourceListParsing.test.ts b/unit-tests/resourceListParsing.test.ts new file mode 100644 index 00000000..cbe01552 --- /dev/null +++ b/unit-tests/resourceListParsing.test.ts @@ -0,0 +1,101 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { + extractResourcesFromMcpTableContent, + extractResourcesFromVerticalListing, + getResourceListToolContexts, + hasBulkResourceListTool, + isVerticalListNoiseLine, +} from '../src/resourceListParsing'; +import { isPlausibleResourceName } from '../src/resourceRefs'; + +describe('extractResourcesFromMcpTableContent', () => { + it('parses namespaced core and OpenShift table rows', () => { + const content = `NAMESPACE APIVERSION KIND NAME DATA AGE LABELS +payments v1 ConfigMap api-config 2 5m app=payments +payments apps/v1 Deployment payments-api 3 10m app=payments +payments route.openshift.io/v1 Route payments-route 15m host=payments`; + + const refs = extractResourcesFromMcpTableContent(content); + strictEqual(refs.length, 3); + strictEqual(refs[0].kind, 'ConfigMap'); + strictEqual(refs[0].name, 'api-config'); + strictEqual(refs[0].namespace, 'payments'); + strictEqual(refs[1].kind, 'Deployment'); + strictEqual(refs[2].kind, 'Route'); + strictEqual(refs[2].name, 'payments-route'); + }); + + it('parses cluster-scoped table rows', () => { + const content = `APIVERSION KIND NAME STATUS AGE LABELS +v1 Namespace default Active 3h38m kubernetes.io/metadata.name=default +v1 Node worker-1.example.internal Ready worker 3h17m`; + + const refs = extractResourcesFromMcpTableContent(content); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[1].kind, 'Node'); + strictEqual(refs[1].name, 'worker-1.example.internal'); + }); +}); + +describe('extractResourcesFromVerticalListing', () => { + it('skips status, age, and replica noise lines', () => { + const refs = extractResourcesFromVerticalListing( + `payments-api +2/2 +Running +3h10m`, + [{ kind: 'Deployment', namespace: 'payments' }], + isPlausibleResourceName, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Deployment'); + strictEqual(refs[0].name, 'payments-api'); + strictEqual(refs[0].namespace, 'payments'); + }); +}); + +describe('getResourceListToolContexts', () => { + it('collects contexts for default and OpenShift list tools', () => { + const contexts = getResourceListToolContexts({ + t1: { + args: { apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'payments' }, + content: '', + name: 'resources_list', + status: 'success', + }, + t2: { + args: { apiVersion: 'route.openshift.io/v1', kind: 'Route' }, + content: '', + name: 'resources_list', + status: 'success', + }, + t3: { + args: {}, + content: '', + name: 'namespaces_list', + status: 'success', + }, + }); + + strictEqual(contexts.length, 3); + strictEqual( + hasBulkResourceListTool({ + t1: { args: {}, content: '', name: 'pods_list', status: 'success' }, + }), + true, + ); + }); +}); + +describe('isVerticalListNoiseLine', () => { + it('treats common kubectl column values as noise', () => { + strictEqual(isVerticalListNoiseLine('3h38m'), true); + strictEqual(isVerticalListNoiseLine('2/2'), true); + strictEqual(isVerticalListNoiseLine('Running'), true); + strictEqual(isVerticalListNoiseLine('payments-api'), false); + }); +}); diff --git a/unit-tests/resourceRefs.test.ts b/unit-tests/resourceRefs.test.ts new file mode 100644 index 00000000..134b0b23 --- /dev/null +++ b/unit-tests/resourceRefs.test.ts @@ -0,0 +1,455 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { + buildResourceConsolePath, + extractNodesFromTopContent, + extractResourceRefs, + extractResourcesFromText, + extractResourcesFromToolContent, + isPlausibleResourceName, +} from '../src/resourceRefs'; +import { testK8sModels } from './fixtures/k8sModels'; + +describe('extractResourcesFromText', () => { + it('matches known kinds and ignores unknown PascalCase words', () => { + const refs = extractResourcesFromText( + 'Pod payments-api in namespace payments while OpenShift Lightspeed investigates.', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'payments-api'); + strictEqual(refs[0].namespace, 'payments'); + }); + + it('resolves CRD kind names from models', () => { + const refs = extractResourcesFromText( + 'VirtualMachine my-vm in namespace default failed to start.', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'kubevirt.io~v1~VirtualMachine'); + }); + + it('ignores alert prose false positives', () => { + const text = `Check the ClusterVersion object and consider clearing spec.channel. +Alertmanager is not configured. MachineConfigPool will never finish updating.`; + const refs = extractResourcesFromText(text, testK8sModels); + strictEqual(refs.length, 0); + }); + + it('parses Name: pod from LLM summary when pods tool ran', () => { + const refs = extractResourceRefs( + { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: '', + name: 'pods_list_in_namespace', + status: 'success', + }, + }, + 'Name: lightspeed-console-plugin-588df96978-7ngf7\nStatus: Running', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'lightspeed-console-plugin-588df96978-7ngf7'); + strictEqual(refs[0].namespace, 'openshift-lightspeed'); + }); +}); + +describe('extractResourceRefs', () => { + it('normalizes refs to model keys', () => { + const refs = extractResourceRefs( + undefined, + 'Deployment reporting-service in namespace shared-services is unhealthy.', + testK8sModels, + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Deployment'); + }); + + it('extracts pods from pods_list_in_namespace table content', () => { + const tableContent = `NAMESPACE APIVERSION KIND NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES LABELS +openshift-lightspeed v1 Pod lightspeed-app-server-6975d49c5c-5rdlk 2/2 Running 0 2m29s 10.130.0.23 crc IP +openshift-lightspeed v1 Pod lightspeed-console-plugin-588df96978-7ngf7 1/1 Running 0 2m29s 10.129.0.20 crc IP `; + + const refs = extractResourceRefs( + { + t1: { + args: { namespace: 'openshift-lightspeed' }, + content: tableContent, + name: 'pods_list_in_namespace', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'lightspeed-app-server-6975d49c5c-5rdlk'); + strictEqual(refs[0].namespace, 'openshift-lightspeed'); + }); + + it('extracts nodes from nodes_top table content', () => { + const tableContent = `NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) +ip-10-0-110-168.us-west-1.compute.internal 117m 3% 3233Mi 22% +ip-10-0-19-247.us-west-1.compute.internal 710m 20% 10120Mi 69%`; + + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: tableContent, + name: 'nodes_top', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'ip-10-0-110-168.us-west-1.compute.internal'); + strictEqual(refs[1].name, 'ip-10-0-19-247.us-west-1.compute.internal'); + }); + + it('extracts nodes from resources_list table content', () => { + const tableContent = `APIVERSION KIND NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME +v1 Node ip-10-0-110-168.us-west-1.compute.internal Ready worker 3h17m Red Hat Enterprise Linux CoreOS 10.0.110.168 +v1 Node ip-10-0-19-247.us-west-1.compute.internal Ready control-plane,master 3h35m Red Hat Enterprise Linux CoreOS 10.0.19.247`; + + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'v1', kind: 'Node' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'ip-10-0-110-168.us-west-1.compute.internal'); + }); + + it('extracts nodes from resources_list structured content', () => { + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'v1', kind: 'Node' }, + content: '', + name: 'resources_list', + status: 'success', + structuredContent: { + items: [ + { Name: 'ip-10-0-52-226.us-west-1.compute.internal', Status: 'Ready' }, + { Name: 'ip-10-0-82-49.us-west-1.compute.internal', Status: 'Ready' }, + ], + }, + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[1].name, 'ip-10-0-82-49.us-west-1.compute.internal'); + }); + + it('extracts namespaces from namespaces_list table content', () => { + const tableContent = `APIVERSION KIND NAME STATUS AGE LABELS +v1 Namespace default Active 3h38m kubernetes.io/metadata.name=default +v1 Namespace openshift-lightspeed Active 169m pod-security.kubernetes.io/enforce=restricted`; + + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: tableContent, + name: 'namespaces_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + strictEqual(refs[1].name, 'openshift-lightspeed'); + }); + + it('extracts projects from projects_list with project route', () => { + const tableContent = `APIVERSION KIND NAME STATUS AGE +v1 Project payments Active 3h +v1 Project shared-services Active 1d`; + + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: tableContent, + name: 'projects_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'payments'); + strictEqual(refs[0].useProjectRoute, true); + strictEqual(buildResourceConsolePath(refs[0], testK8sModels), '/k8s/cluster/projects/payments'); + }); + + it('extracts involved objects from events_list YAML content', () => { + const eventsContent = `# The following events (YAML format) were found: +- InvolvedObject: + APIVersion: v1 + Kind: Pod + Name: redhat-operators-2mhcl + Namespace: openshift-redhat-operators + Message: Startup probe failed + Type: Warning`; + + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: eventsContent, + name: 'events_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'redhat-operators-2mhcl'); + strictEqual(refs[0].namespace, 'openshift-redhat-operators'); + }); + + it('extracts pod names from prose when pods or events tools ran', () => { + const refs = extractResourceRefs( + { + t1: { args: {}, content: '', name: 'pods_list', status: 'success' }, + t2: { args: {}, content: '', name: 'events_list', status: 'success' }, + }, + 'Events indicate a warning related to the redhat-operators-2mhcl pod.', + testK8sModels, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'redhat-operators-2mhcl'); + }); + + it('extracts namespaces from LLM vertical listing when namespaces_list ran', () => { + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: '', + name: 'namespaces_list', + status: 'success', + }, + }, + `Here are the OpenShift namespaces in the current cluster: + +default +Active +3h38m +openshift-lightspeed +Active +169m`, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + strictEqual(refs[1].name, 'openshift-lightspeed'); + }); + + it('extracts namespaces from namespaces_list YAML array', () => { + const refs = extractResourcesFromToolContent( + `- apiVersion: v1 + kind: Namespace + metadata: + name: default +- apiVersion: v1 + kind: Namespace + metadata: + name: kube-system`, + 'namespaces_list', + ); + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[0].name, 'default'); + strictEqual(refs[1].name, 'kube-system'); + }); + + it('extracts deployments from resources_list table content', () => { + const tableContent = `NAMESPACE APIVERSION KIND NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR +payments apps/v1 Deployment payments-api 2/2 2 2 3h10m payments-api registry/payments:latest app=payments +payments apps/v1 Deployment reporting-service 1/1 1 1 2h5m reporting-service registry/reporting:latest app=reporting`; + + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'payments' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Deployment'); + strictEqual(refs[0].name, 'payments-api'); + strictEqual(refs[0].namespace, 'payments'); + }); + + it('extracts routes from resources_list table content', () => { + const tableContent = `NAMESPACE APIVERSION KIND NAME HOST/PORT PATH SERVICES PORT TERMINATION WILDCARD AGE LABELS +payments route.openshift.io/v1 Route payments-route payments.example.com / payments-api 8080 edge 15m app=payments`; + + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'route.openshift.io/v1', kind: 'Route' }, + content: tableContent, + name: 'resources_list', + status: 'success', + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'route.openshift.io~v1~Route'); + strictEqual(refs[0].name, 'payments-route'); + strictEqual(refs[0].namespace, 'payments'); + }); + + it('extracts deployments from LLM vertical listing when resources_list ran', () => { + const refs = extractResourceRefs( + { + t1: { + args: { apiVersion: 'apps/v1', kind: 'Deployment', namespace: 'payments' }, + content: '', + name: 'resources_list', + status: 'success', + }, + }, + `Here are the deployments in the payments namespace: + +payments-api +2/2 +3h10m +reporting-service +1/1 +2h5m`, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Deployment'); + strictEqual(refs[0].name, 'payments-api'); + strictEqual(refs[1].name, 'reporting-service'); + }); + + it('extracts namespaces from namespaces_list structured content', () => { + const refs = extractResourceRefs( + { + t1: { + args: {}, + content: '', + name: 'namespaces_list', + status: 'success', + structuredContent: { + items: [ + { Name: 'default', Status: 'Active' }, + { Name: 'openshift-lightspeed', Status: 'Active' }, + ], + }, + }, + }, + undefined, + testK8sModels, + ); + + strictEqual(refs.length, 2); + strictEqual(refs[0].kind, 'Namespace'); + strictEqual(refs[1].name, 'openshift-lightspeed'); + }); +}); + +describe('extractResourcesFromToolContent', () => { + it('parses PodList JSON items', () => { + const refs = extractResourcesFromToolContent( + JSON.stringify({ + kind: 'PodList', + items: [ + { + kind: 'Pod', + metadata: { name: 'api', namespace: 'payments' }, + }, + ], + }), + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].name, 'api'); + }); + + it('parses nodes_top metrics table rows', () => { + const refs = extractResourcesFromToolContent( + `NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) +worker-1 117m 3% 3233Mi 22%`, + 'nodes_top', + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Node'); + strictEqual(refs[0].name, 'worker-1'); + }); +}); + +describe('extractNodesFromTopContent', () => { + it('skips the header row and deduplicates node names', () => { + const refs = extractNodesFromTopContent( + `NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) +node-a 100m 1% 1000Mi 10% +node-a 100m 1% 1000Mi 10% +node-b 200m 2% 2000Mi 20%`, + ); + strictEqual(refs.length, 2); + strictEqual(refs[0].name, 'node-a'); + strictEqual(refs[1].name, 'node-b'); + }); +}); + +describe('isPlausibleResourceName', () => { + it('rejects age values such as 5m', () => { + strictEqual(isPlausibleResourceName('5m'), false); + strictEqual(isPlausibleResourceName('72m'), false); + }); +}); diff --git a/unit-tests/resourceStatus.test.ts b/unit-tests/resourceStatus.test.ts new file mode 100644 index 00000000..a8f4daf3 --- /dev/null +++ b/unit-tests/resourceStatus.test.ts @@ -0,0 +1,210 @@ +import { describe, it } from 'node:test'; +import { strictEqual } from 'node:assert'; + +import { getResourceStatusSummary, isInformativeStatusLabel } from '../src/resourceStatus'; + +describe('getResourceStatusSummary', () => { + it('uses Ready condition for operator CRDs', () => { + const summary = getResourceStatusSummary('FlowCollector', { + status: { + conditions: [{ type: 'Ready', status: 'True', reason: 'Ready' }], + }, + }); + strictEqual(summary.label, 'Ready'); + strictEqual(summary.variant, 'success'); + }); + + it('surfaces Degraded conditions as danger', () => { + const summary = getResourceStatusSummary('FlowCollector', { + status: { + conditions: [{ type: 'Degraded', status: 'True', reason: 'LokiNotReady' }], + }, + }); + strictEqual(summary.label, 'LokiNotReady'); + strictEqual(summary.variant, 'danger'); + }); + + it('uses CSV top-level phase instead of the first condition reason', () => { + const summary = getResourceStatusSummary('ClusterServiceVersion', { + status: { + phase: 'Succeeded', + reason: 'InstallSucceeded', + conditions: [ + { phase: 'Pending', reason: 'RequirementsUnknown' }, + { phase: 'Succeeded', reason: 'InstallSucceeded' }, + ], + }, + }); + strictEqual(summary.label, 'Succeeded (InstallSucceeded)'); + strictEqual(summary.variant, 'success'); + }); + + it('reports CrashLoopBackOff from container state when phase is Running', () => { + const summary = getResourceStatusSummary('Pod', { + status: { + phase: 'Running', + containerStatuses: [ + { + ready: false, + state: { waiting: { reason: 'CrashLoopBackOff' } }, + }, + ], + }, + }); + strictEqual(summary.label, 'CrashLoopBackOff'); + strictEqual(summary.variant, 'danger'); + }); + + it('reports ImagePullBackOff from container waiting state', () => { + const summary = getResourceStatusSummary('Pod', { + status: { + phase: 'Pending', + containerStatuses: [ + { + ready: false, + state: { waiting: { reason: 'ImagePullBackOff' } }, + }, + ], + }, + }); + strictEqual(summary.label, 'ImagePullBackOff'); + strictEqual(summary.variant, 'danger'); + }); + + it('reports ContainerCreating instead of Pending phase', () => { + const summary = getResourceStatusSummary('Pod', { + status: { + phase: 'Pending', + containerStatuses: [ + { + ready: false, + state: { waiting: { reason: 'ContainerCreating' } }, + }, + ], + }, + }); + strictEqual(summary.label, 'ContainerCreating'); + strictEqual(summary.variant, 'warning'); + }); + + it('reports healthy Running pods from phase when containers are ready', () => { + const summary = getResourceStatusSummary('Pod', { + status: { + phase: 'Running', + containerStatuses: [{ ready: true, state: { running: {} } }], + }, + }); + strictEqual(summary.label, 'Running'); + strictEqual(summary.variant, 'success'); + }); + + it('reports Failed phase for failed pods', () => { + const summary = getResourceStatusSummary('Pod', { + status: { phase: 'Failed' }, + }); + strictEqual(summary.label, 'Failed'); + strictEqual(summary.variant, 'danger'); + }); + + it('reports completed jobs from conditions', () => { + const summary = getResourceStatusSummary('Job', { + status: { + succeeded: 1, + conditions: [{ type: 'Complete', status: 'True', reason: 'Complete' }], + }, + }); + strictEqual(summary.label, 'Complete'); + strictEqual(summary.variant, 'success'); + }); + + it('reports failed jobs', () => { + const summary = getResourceStatusSummary('Job', { + status: { + failed: 1, + conditions: [{ type: 'Failed', status: 'True', reason: 'BackoffLimitExceeded' }], + }, + }); + strictEqual(summary.label, 'Failed'); + strictEqual(summary.variant, 'danger'); + }); + + it('reports suspended cronjobs', () => { + const summary = getResourceStatusSummary('CronJob', { + spec: { suspend: true }, + status: {}, + }); + strictEqual(summary.label, 'Suspended'); + strictEqual(summary.variant, 'warning'); + }); + + it('reports PVC phase', () => { + const summary = getResourceStatusSummary('PersistentVolumeClaim', { + status: { phase: 'Bound' }, + }); + strictEqual(summary.label, 'Bound'); + strictEqual(summary.variant, 'success'); + }); + + it('reports namespace phase', () => { + const summary = getResourceStatusSummary('Namespace', { + status: { phase: 'Terminating' }, + }); + strictEqual(summary.label, 'Terminating'); + strictEqual(summary.variant, 'warning'); + }); + + it('reports pending load balancer services', () => { + const summary = getResourceStatusSummary('Service', { + spec: { type: 'LoadBalancer' }, + status: { loadBalancer: {} }, + }); + strictEqual(summary.label, 'Pending'); + strictEqual(summary.variant, 'warning'); + }); + + it('returns em dash for ClusterIP services (type is not status)', () => { + const summary = getResourceStatusSummary('Service', { + spec: { type: 'ClusterIP' }, + }); + strictEqual(summary.label, '—'); + strictEqual(isInformativeStatusLabel(summary.label), false); + }); + + it('reports HPA replica counts', () => { + const summary = getResourceStatusSummary('HorizontalPodAutoscaler', { + status: { currentReplicas: 2, desiredReplicas: 3 }, + }); + strictEqual(summary.label, '2/3 replicas'); + strictEqual(summary.variant, 'warning'); + }); + + it('reports ingress readiness from load balancer status', () => { + const summary = getResourceStatusSummary('Ingress', { + status: { loadBalancer: { ingress: [{ ip: '10.0.0.1' }] } }, + }); + strictEqual(summary.label, 'Ready'); + strictEqual(summary.variant, 'success'); + }); + + it('returns em dash for status-less resources like ConfigMap', () => { + const summary = getResourceStatusSummary('ConfigMap', { + metadata: { name: 'app-config' }, + }); + strictEqual(summary.label, '—'); + strictEqual(isInformativeStatusLabel(summary.label), false); + }); +}); + +describe('isInformativeStatusLabel', () => { + it('rejects empty and placeholder labels', () => { + strictEqual(isInformativeStatusLabel(''), false); + strictEqual(isInformativeStatusLabel(' '), false); + strictEqual(isInformativeStatusLabel('-'), false); + strictEqual(isInformativeStatusLabel('—'), false); + }); + + it('accepts real status text', () => { + strictEqual(isInformativeStatusLabel('Running'), true); + strictEqual(isInformativeStatusLabel('2/3 ready'), true); + }); +}); diff --git a/webpack.config.ts b/webpack.config.ts index bac34cae..0052c1dc 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -79,7 +79,13 @@ const config: Configuration = { }), new ConsoleRemotePlugin(), new CopyWebpackPlugin({ - patterns: [{ from: path.resolve(__dirname, 'locales'), to: 'locales' }], + patterns: [ + { + context: path.resolve(__dirname, 'locales'), + from: '**/*', + to: 'locales/[path][name][ext]', + }, + ], }), ], devtool: 'source-map', From 85fec1e17af7444f2a9ccbd983d5d21580db7f08 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Mon, 6 Jul 2026 14:36:20 +0200 Subject: [PATCH 2/4] addressed rabbit suggestions --- .eslintrc.yml | 5 ++++ scripts/mock-ols-server.mjs | 2 +- src/components/GeneralPage.tsx | 5 ++-- src/components/InlineLinkedResource.tsx | 11 +++----- src/components/LinkedResourceStatus.tsx | 4 +-- src/crStatus.ts | 4 +-- src/hooks/useConsoleNavigation.ts | 6 ++++- src/hooks/useMessageSources.tsx | 2 +- src/linkedResourceStatusDisplay.ts | 8 +++--- src/linkedResourceWatch.ts | 10 ++++++- src/linkedResources.ts | 27 +++++++++++++------ src/resourceListParsing.ts | 2 +- src/resourceRefs.ts | 11 ++++---- src/resourceStatus.ts | 5 +++- start-console.sh | 14 +++++++--- unit-tests/crStatus.test.ts | 13 +++++++++ .../linkedResourceStatusDisplay.test.ts | 7 +++++ unit-tests/linkedResourceWatch.test.ts | 13 +++++++++ unit-tests/resourceRefs.test.ts | 12 +++++++++ unit-tests/resourceStatus.test.ts | 17 ++++++++++++ 20 files changed, 138 insertions(+), 40 deletions(-) diff --git a/.eslintrc.yml b/.eslintrc.yml index dc0ef27e..e3275ece 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -82,3 +82,8 @@ rules: settings: react: version: detect +overrides: + - files: + - scripts/**/*.mjs + env: + node: true diff --git a/scripts/mock-ols-server.mjs b/scripts/mock-ols-server.mjs index e9df2115..d27c3512 100755 --- a/scripts/mock-ols-server.mjs +++ b/scripts/mock-ols-server.mjs @@ -970,7 +970,7 @@ const server = createServer(async (req, res) => { // ignore parse errors; still return a mock stream } - const { body, scenarioKey, conversationId } = buildStreamBody(query); + const { body, scenarioKey } = buildStreamBody(query); // eslint-disable-next-line no-console console.log(`[mock-ols] streaming_query scenario=${scenarioKey} query=${JSON.stringify(query)}`); diff --git a/src/components/GeneralPage.tsx b/src/components/GeneralPage.tsx index 595d0964..dd60f4f0 100644 --- a/src/components/GeneralPage.tsx +++ b/src/components/GeneralPage.tsx @@ -171,12 +171,13 @@ const ChatHistoryEntry = React.memo( const [isDarkTheme] = useIsDarkTheme(); const isAiEntry = entry?.who === 'ai'; + const isAiResponseReady = isAiEntry && !entry.isStreaming && !entry.error; const messageSources = useMessageSources({ - enabled: isAiEntry && !entry.isStreaming && !entry.error && modelsLoaded, + enabled: isAiResponseReady, references: isAiEntry ? entry.references : undefined, }); const linkedResourceMarkdown = useLinkedResourceMarkdown({ - enabled: isAiEntry && !entry.isStreaming && !entry.error && modelsLoaded, + enabled: isAiResponseReady && modelsLoaded, k8sModels, responseText: isAiEntry ? entry.text : undefined, tools: aiTools, diff --git a/src/components/InlineLinkedResource.tsx b/src/components/InlineLinkedResource.tsx index 39b595ff..ee64669c 100644 --- a/src/components/InlineLinkedResource.tsx +++ b/src/components/InlineLinkedResource.tsx @@ -5,7 +5,7 @@ import LinkedResourceStatus from './LinkedResourceStatus'; import ResourceIcon from './ResourceIcon'; import { buildResourceConsolePath, getResourceIconKind, K8sModelRef } from '../pageContext'; import { shouldShowLinkedResourceStatus } from '../linkedResourceWatch'; -import { ResourceRef, resourceRefKey } from '../resourceRefs'; +import { ResourceRef } from '../resourceRefs'; import './linked-resources.css'; @@ -32,6 +32,7 @@ const InlineLinkedResource: React.FC = ({ > {shouldShowLinkedResourceStatus(resourceRef, k8sModels) && ( - + )} ); diff --git a/src/components/LinkedResourceStatus.tsx b/src/components/LinkedResourceStatus.tsx index a11eedfa..76a14535 100644 --- a/src/components/LinkedResourceStatus.tsx +++ b/src/components/LinkedResourceStatus.tsx @@ -24,7 +24,7 @@ const LinkedResourceStatus: React.FC = ({ k8sModels, const [resource, loaded, loadError] = useK8sWatchResource(watchProps); - const hasMatchingResource = matchesResourceRef(resource, resourceRef); + const hasMatchingResource = matchesResourceRef(resource, resourceRef, k8sModels); if (!watchProps) { return null; @@ -34,7 +34,7 @@ const LinkedResourceStatus: React.FC = ({ k8sModels, return null; } - if (!loaded || !hasMatchingResource) { + if (!loaded) { return ; } diff --git a/src/crStatus.ts b/src/crStatus.ts index e5d1125a..9de1c147 100644 --- a/src/crStatus.ts +++ b/src/crStatus.ts @@ -1,6 +1,6 @@ import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; -import { StatusSummary } from './resourceStatus'; +import type { StatusSummary } from './resourceStatus'; type GenericCondition = { lastTransitionTime?: string; @@ -136,7 +136,7 @@ const getStandardConditionStatus = (conditions: GenericCondition[]): StatusSumma return null; } - const priority = ['Ready', 'Available', 'Progressing', 'Degraded', 'Failure']; + const priority = ['Degraded', 'Failure', 'Ready', 'Available', 'Progressing']; for (const type of priority) { const condition = standard.find((entry) => entry.type === type); if (!condition) { diff --git a/src/hooks/useConsoleNavigation.ts b/src/hooks/useConsoleNavigation.ts index e2e7a890..058ce10c 100644 --- a/src/hooks/useConsoleNavigation.ts +++ b/src/hooks/useConsoleNavigation.ts @@ -15,7 +15,11 @@ export const useConsoleNavigation = (): ((path: string) => void) => { try { navigate(path); } catch { - navigateToConsolePath(path); + try { + navigateToConsolePath(path); + } catch { + // Fail closed: navigation is best-effort from the Lightspeed modal. + } } }, [navigate], diff --git a/src/hooks/useMessageSources.tsx b/src/hooks/useMessageSources.tsx index 01afd70d..4b297228 100644 --- a/src/hooks/useMessageSources.tsx +++ b/src/hooks/useMessageSources.tsx @@ -7,7 +7,7 @@ import { ReferencedDoc } from '../types'; const isURL = (value: string): boolean => { try { const url = new URL(value); - return !!(url.protocol && url.host); + return url.protocol === 'http:' || url.protocol === 'https:'; } catch { return false; } diff --git a/src/linkedResourceStatusDisplay.ts b/src/linkedResourceStatusDisplay.ts index cb1191c7..8082b979 100644 --- a/src/linkedResourceStatusDisplay.ts +++ b/src/linkedResourceStatusDisplay.ts @@ -62,14 +62,14 @@ export const toConsoleStatusKey = ( return withIcon(label); } - if (label.endsWith(' ready')) { - return withIcon(mapReplicaLabel(summary)); - } - if (label === 'LoadBalancer ready') { return withIcon('Ready'); } + if (label.endsWith(' ready')) { + return withIcon(mapReplicaLabel(summary)); + } + if (kindName === 'Job') { if (variant === 'danger') { return withIcon('Failed'); diff --git a/src/linkedResourceWatch.ts b/src/linkedResourceWatch.ts index ab76ac2a..a2c2a3c1 100644 --- a/src/linkedResourceWatch.ts +++ b/src/linkedResourceWatch.ts @@ -1,6 +1,6 @@ import { K8sResourceKind } from '@openshift-console/dynamic-plugin-sdk'; -import { K8sModelRef, resolveRefModelKey } from './pageContext'; +import { getModelKindName, K8sModelRef, resolveRefModelKey } from './pageContext'; import { ResourceRef } from './resourceRefs'; export type ResourceWatchProps = { @@ -42,6 +42,7 @@ export const buildResourceWatchProps = ( export const matchesResourceRef = ( resource: K8sResourceKind | undefined, resourceRef: ResourceRef, + k8sModels: Record, ): boolean => { if (!resource?.metadata?.name) { return false; @@ -55,6 +56,13 @@ export const matchesResourceRef = ( ) { return false; } + const modelKey = resolveRefModelKey(resourceRef, k8sModels); + if (modelKey && resource.kind) { + const expectedKind = getModelKindName(modelKey, k8sModels); + if (resource.kind !== expectedKind) { + return false; + } + } return true; }; diff --git a/src/linkedResources.ts b/src/linkedResources.ts index 0c7d0ecc..cbb467f8 100644 --- a/src/linkedResources.ts +++ b/src/linkedResources.ts @@ -1,10 +1,16 @@ -import { getModelKindName, K8sModelRef, resolveRefModelKey, resolveRefModelKeyOrKind } from './pageContext'; +import { + getModelKindName, + K8sModelRef, + resolveRefModelKey, + resolveRefModelKeyOrKind, +} from './pageContext'; import { hasBulkResourceListTool } from './resourceListParsing'; import { extractResourceRefs, getMentionedResourceNames, isPlausibleResourceName, ResourceRef, + resourceRefKey, } from './resourceRefs'; import { Tool } from './types'; @@ -26,20 +32,24 @@ export const prioritizeLinkedResources = ( return refs; } - const byName = new Map(refs.map((ref) => [ref.name.toLowerCase(), ref])); const prioritized: ResourceRef[] = []; const used = new Set(); for (const name of mentionedNames) { - const ref = byName.get(name); - if (ref) { - prioritized.push(ref); - used.add(name); + for (const ref of refs) { + if (ref.name.toLowerCase() !== name) { + continue; + } + const key = resourceRefKey(ref); + if (!used.has(key)) { + prioritized.push(ref); + used.add(key); + } } } for (const ref of refs) { - const key = ref.name.toLowerCase(); + const key = resourceRefKey(ref); if (!used.has(key)) { prioritized.push(ref); } @@ -115,7 +125,8 @@ export const getLinkedResourceOverflow = ( models: Record, ): { shown: number; total: number } => { const linkable = extractLinkableResourceRefs(tools, responseText, models); - const shown = extractLinkedResources(tools, responseText, models).length; + const limit = resolveLinkedResourceLimit(tools, linkable.length); + const shown = prioritizeLinkedResources(linkable, responseText, tools).slice(0, limit).length; return { shown, total: linkable.length }; }; diff --git a/src/resourceListParsing.ts b/src/resourceListParsing.ts index 49cca746..89dbb2c9 100644 --- a/src/resourceListParsing.ts +++ b/src/resourceListParsing.ts @@ -1,7 +1,7 @@ import { ResourceRef } from './pageContext'; import { Tool } from './types'; -const K8S_NODE_NAME_RE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; +export const K8S_NODE_NAME_RE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; const RESOURCE_AGE_LINE = /^\d+([wdhms]\d*)+$/i; diff --git a/src/resourceRefs.ts b/src/resourceRefs.ts index ea061d68..0151f62b 100644 --- a/src/resourceRefs.ts +++ b/src/resourceRefs.ts @@ -9,6 +9,7 @@ import { hasBulkResourceListTool, isResourceListToolName, isVerticalListNoiseLine, + K8S_NODE_NAME_RE, toNamespaceRef, } from './resourceListParsing'; import { Tool } from './types'; @@ -111,8 +112,6 @@ export const isPlausibleResourceName = ( return true; }; -const K8S_NODE_NAME_RE = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/; - export const extractNodesFromTopContent = (content: string): ResourceRef[] => { const refs: ResourceRef[] = []; const seen = new Set(); @@ -246,12 +245,12 @@ const extractRefsFromStructuredListItems = ( const listElementKind = (listKind: string): string | undefined => listKind.endsWith('List') ? listKind.slice(0, -4) : undefined; -const extractRefFromObject = (obj: unknown): ResourceRef | null => { +const extractRefFromObject = (obj: unknown, defaultKind?: string): ResourceRef | null => { if (!obj || typeof obj !== 'object') { return null; } const record = obj as { kind?: string; metadata?: { name?: string; namespace?: string } }; - const kind = record.kind; + const kind = record.kind ?? defaultKind; const name = record.metadata?.name; const namespace = record.metadata?.namespace; if (kind && name) { @@ -323,12 +322,12 @@ const extractRefsFromListDocument = ( const defaultKind = parsed.kind ? listElementKind(parsed.kind) : undefined; const refs: ResourceRef[] = []; for (const item of parsed.items) { - const ref = extractRefFromObject(item); + const ref = extractRefFromObject(item, defaultKind); if (!ref) { continue; } refs.push({ - kind: ref.kind ?? defaultKind ?? 'Pod', + kind: ref.kind, name: ref.name, namespace: ref.namespace ?? fallbackNamespace, }); diff --git a/src/resourceStatus.ts b/src/resourceStatus.ts index 101b1e6b..aaff8c69 100644 --- a/src/resourceStatus.ts +++ b/src/resourceStatus.ts @@ -120,7 +120,10 @@ const workloadReplicaStatus = (resource: K8sResourceKind): StatusSummary => { resource.status?.replicas ?? ready; const label = `${ready}/${total} ready`; - if (ready === total && total > 0) { + if (total === 0) { + return { label, variant: 'info' }; + } + if (ready === total) { return { label, variant: 'success' }; } if (ready > 0) { diff --git a/start-console.sh b/start-console.sh index c7568808..01a49492 100755 --- a/start-console.sh +++ b/start-console.sh @@ -48,6 +48,10 @@ resolve_bearer_token() { fi # Plain Kubernetes clusters (e.g. kind) often use client certs; mint a dev token instead. + if [ "${MOCK_ALLOW_DEV_TOKEN_MINT:-}" != "1" ]; then + echo "error: no bearer token in kubeconfig. Set MOCK_ALLOW_DEV_TOKEN_MINT=1 to create a cluster-admin dev ServiceAccount." >&2 + return 1 + fi if ! kubectl get serviceaccount "$CONSOLE_DEV_SA_NAME" -n "$CONSOLE_DEV_SA_NAMESPACE" >/dev/null 2>&1; then kubectl create serviceaccount "$CONSOLE_DEV_SA_NAME" -n "$CONSOLE_DEV_SA_NAMESPACE" >/dev/null fi @@ -61,17 +65,21 @@ resolve_bearer_token() { echo "Starting local OpenShift console..." -if ! command -v oc >/dev/null 2>&1 && ! command -v kubectl >/dev/null 2>&1; then +if command -v oc >/dev/null 2>&1; then + PREFERRED_CLI=oc +elif command -v kubectl >/dev/null 2>&1; then + PREFERRED_CLI=kubectl +else echo "error: neither oc nor kubectl found in PATH." >&2 exit 1 fi -if command -v oc >/dev/null 2>&1 && ! oc whoami >/dev/null 2>&1; then +if [ "$PREFERRED_CLI" = oc ] && ! oc whoami >/dev/null 2>&1; then echo "error: not logged in to a cluster. Run 'oc login' (or point KUBECONFIG at a running cluster) and retry." >&2 exit 1 fi -if command -v kubectl >/dev/null 2>&1 && ! kubectl cluster-info >/dev/null 2>&1; then +if [ "$PREFERRED_CLI" = kubectl ] && ! kubectl cluster-info >/dev/null 2>&1; then echo "error: cannot reach the Kubernetes API. Is your cluster running?" >&2 echo " Try: kubectl cluster-info" >&2 exit 1 diff --git a/unit-tests/crStatus.test.ts b/unit-tests/crStatus.test.ts index 424f24ca..e030160e 100644 --- a/unit-tests/crStatus.test.ts +++ b/unit-tests/crStatus.test.ts @@ -60,4 +60,17 @@ describe('getGenericResourceStatus', () => { strictEqual(summary.label, 'Failed (ComponentUnhealthy)'); strictEqual(summary.variant, 'danger'); }); + + it('prefers Degraded over Available when both are True', () => { + const summary = getGenericResourceStatus({ + status: { + conditions: [ + { type: 'Available', status: 'True', reason: 'MinimumReplicasAvailable' }, + { type: 'Degraded', status: 'True', reason: 'RolloutStalled' }, + ], + }, + }); + strictEqual(summary.label, 'RolloutStalled'); + strictEqual(summary.variant, 'danger'); + }); }); diff --git a/unit-tests/linkedResourceStatusDisplay.test.ts b/unit-tests/linkedResourceStatusDisplay.test.ts index a8fdb521..40290adb 100644 --- a/unit-tests/linkedResourceStatusDisplay.test.ts +++ b/unit-tests/linkedResourceStatusDisplay.test.ts @@ -43,6 +43,13 @@ describe('toConsoleStatusKey', () => { }); }); + it('maps LoadBalancer ready before generic replica labels', () => { + deepStrictEqual( + toConsoleStatusKey({ label: 'LoadBalancer ready', variant: 'success' }, 'Service'), + { status: 'Ready', useIcon: true }, + ); + }); + it('maps replica counts to console workload icons', () => { deepStrictEqual(toConsoleStatusKey({ label: '1/1 ready', variant: 'success' }, 'Deployment'), { status: 'Running', diff --git a/unit-tests/linkedResourceWatch.test.ts b/unit-tests/linkedResourceWatch.test.ts index a55f2d8b..bb4291a4 100644 --- a/unit-tests/linkedResourceWatch.test.ts +++ b/unit-tests/linkedResourceWatch.test.ts @@ -36,6 +36,18 @@ describe('matchesResourceRef', () => { matchesResourceRef( { metadata: { name: 'default' } }, { kind: 'Namespace', name: 'kube-system' }, + testK8sModels, + ), + false, + ); + }); + + it('rejects stale watch data for a different kind', () => { + strictEqual( + matchesResourceRef( + { kind: 'Pod', metadata: { name: 'api-1', namespace: 'payments' } }, + { kind: 'Deployment', name: 'api-1', namespace: 'payments' }, + testK8sModels, ), false, ); @@ -46,6 +58,7 @@ describe('matchesResourceRef', () => { matchesResourceRef( { metadata: { name: 'payments' } }, { kind: 'Namespace', name: 'payments', useProjectRoute: true }, + testK8sModels, ), true, ); diff --git a/unit-tests/resourceRefs.test.ts b/unit-tests/resourceRefs.test.ts index 134b0b23..bfe3565b 100644 --- a/unit-tests/resourceRefs.test.ts +++ b/unit-tests/resourceRefs.test.ts @@ -421,6 +421,18 @@ describe('extractResourcesFromToolContent', () => { strictEqual(refs[0].name, 'api'); }); + it('uses list kind when items omit kind', () => { + const refs = extractResourcesFromToolContent( + JSON.stringify({ + kind: 'PodList', + items: [{ metadata: { name: 'api', namespace: 'payments' } }], + }), + ); + strictEqual(refs.length, 1); + strictEqual(refs[0].kind, 'Pod'); + strictEqual(refs[0].name, 'api'); + }); + it('parses nodes_top metrics table rows', () => { const refs = extractResourcesFromToolContent( `NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) diff --git a/unit-tests/resourceStatus.test.ts b/unit-tests/resourceStatus.test.ts index a8f4daf3..bb72e380 100644 --- a/unit-tests/resourceStatus.test.ts +++ b/unit-tests/resourceStatus.test.ts @@ -186,6 +186,23 @@ describe('getResourceStatusSummary', () => { strictEqual(summary.variant, 'success'); }); + it('reports scaled-to-zero workloads as neutral', () => { + const summary = getResourceStatusSummary('Deployment', { + status: { replicas: 0, readyReplicas: 0 }, + }); + strictEqual(summary.label, '0/0 ready'); + strictEqual(summary.variant, 'info'); + }); + + it('reports ready LoadBalancer services', () => { + const summary = getResourceStatusSummary('Service', { + spec: { type: 'LoadBalancer' }, + status: { loadBalancer: { ingress: [{ ip: '10.0.0.1' }] } }, + }); + strictEqual(summary.label, 'LoadBalancer ready'); + strictEqual(summary.variant, 'success'); + }); + it('returns em dash for status-less resources like ConfigMap', () => { const summary = getResourceStatusSummary('ConfigMap', { metadata: { name: 'app-config' }, From 495da124732fe386c1badbeb6dad773aacbc3ad5 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Mon, 6 Jul 2026 14:59:26 +0200 Subject: [PATCH 3/4] add kind script --- README.md | 5 +++++ package.json | 1 + scripts/mock-ols-server.mjs | 2 +- start-console.sh | 16 ++++++++++++---- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d4e68f6b..084cdb62 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,11 @@ match this plugin's SDK version. If the Lightspeed chat icon is missing, check the browser console for plugin errors and try pinning the image explicitly: `CONSOLE_TAG=4.22 npm run start-console`. +**kind / plain Kubernetes:** kubeconfigs often use client certificates instead of +bearer tokens. Use `npm run start-console-kind` (or +`ALLOW_DEV_TOKEN_MINT=1 npm run start-console`) to mint a temporary dev +ServiceAccount token. This creates cluster-admin credentials for local use only. + #### Running start-console with Apple silicon and podman If you are using podman on a Mac with Apple silicon, `npm run start-console` diff --git a/package.json b/package.json index 00a17594..b0f1bca0 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "start": "npm run ts-node node_modules/.bin/webpack serve", "start-mock-ols": "node scripts/mock-ols-server.mjs", "start-console": "./start-console.sh", + "start-console-kind": "ALLOW_DEV_TOKEN_MINT=1 ./start-console.sh", "i18n": "./i18n-scripts/build-i18n.sh && node ./i18n-scripts/set-english-defaults.js", "ts-node": "ts-node -O '{\"module\":\"commonjs\"}'", "lint": "eslint ./*.{js,ts} ./src ./tests ./unit-tests && stylelint \"src/**/*.css\" --allow-empty-input", diff --git a/scripts/mock-ols-server.mjs b/scripts/mock-ols-server.mjs index d27c3512..d34106ce 100755 --- a/scripts/mock-ols-server.mjs +++ b/scripts/mock-ols-server.mjs @@ -13,7 +13,7 @@ * * No OpenShift cluster? Use a local kind cluster: * kind create cluster - * npm run start-console # start-console.sh mints a dev token for plain Kubernetes + * npm run start-console-kind # mints a dev token (ALLOW_DEV_TOKEN_MINT=1) * * Then open http://localhost:9000, open Lightspeed, and try e.g.: * - "show pods" → pods in MOCK_NAMESPACE diff --git a/start-console.sh b/start-console.sh index 01a49492..b92d83c9 100755 --- a/start-console.sh +++ b/start-console.sh @@ -11,6 +11,10 @@ OLS_PORT=${OLS_PORT:=8080} CONSOLE_DEV_SA_NAME=${CONSOLE_DEV_SA_NAME:=lightspeed-console-dev} CONSOLE_DEV_SA_NAMESPACE=${CONSOLE_DEV_SA_NAMESPACE:=kube-system} +dev_token_mint_allowed() { + [ "${ALLOW_DEV_TOKEN_MINT:-}" = "1" ] || [ "${MOCK_ALLOW_DEV_TOKEN_MINT:-}" = "1" ] +} + resolve_api_server() { if command -v oc >/dev/null 2>&1 && oc whoami >/dev/null 2>&1; then oc whoami --show-server @@ -48,8 +52,11 @@ resolve_bearer_token() { fi # Plain Kubernetes clusters (e.g. kind) often use client certs; mint a dev token instead. - if [ "${MOCK_ALLOW_DEV_TOKEN_MINT:-}" != "1" ]; then - echo "error: no bearer token in kubeconfig. Set MOCK_ALLOW_DEV_TOKEN_MINT=1 to create a cluster-admin dev ServiceAccount." >&2 + if ! dev_token_mint_allowed; then + echo "error: no bearer token in kubeconfig (common with kind/minikube client-cert auth)." >&2 + echo " Run: ALLOW_DEV_TOKEN_MINT=1 npm run start-console" >&2 + echo " or: npm run start-console-kind" >&2 + echo " This creates a temporary cluster-admin ServiceAccount (${CONSOLE_DEV_SA_NAMESPACE}/${CONSOLE_DEV_SA_NAME})." >&2 return 1 fi if ! kubectl get serviceaccount "$CONSOLE_DEV_SA_NAME" -n "$CONSOLE_DEV_SA_NAMESPACE" >/dev/null 2>&1; then @@ -91,8 +98,9 @@ BRIDGE_K8S_AUTH_BEARER_TOKEN=$(resolve_bearer_token || true) if [ -z "$BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT" ] || [ -z "$BRIDGE_K8S_AUTH_BEARER_TOKEN" ]; then echo "error: could not read API server or bearer token." >&2 echo " server: ${BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT:-}" >&2 - echo " OpenShift: run 'oc whoami --show-token'." >&2 - echo " kind/k8s: ensure 'kubectl create token' works, or log in with a kubeconfig that includes a user token." >&2 + echo " OpenShift: run 'oc login', then 'oc whoami --show-token'." >&2 + echo " kind/minikube: npm run start-console-kind" >&2 + echo " (sets ALLOW_DEV_TOKEN_MINT=1 to mint a dev ServiceAccount token)" >&2 exit 1 fi From a803f34b26c49751260fcf5a1f253c1acb0b4261 Mon Sep 17 00:00:00 2001 From: Julien Pinsonneau Date: Tue, 7 Jul 2026 10:16:07 +0200 Subject: [PATCH 4/4] Move mock OLS server to mock-ols-server branch Dev-only mock server, start-console-kind, and related README/start-console changes live on the mock-ols-server branch for a separate PR. Co-authored-by: Cursor --- .eslintrc.yml | 5 - README.md | 10 - package.json | 2 - scripts/mock-ols-server.mjs | 1034 ----------------------------------- start-console.sh | 107 +--- 5 files changed, 8 insertions(+), 1150 deletions(-) delete mode 100755 scripts/mock-ols-server.mjs diff --git a/.eslintrc.yml b/.eslintrc.yml index e3275ece..dc0ef27e 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -82,8 +82,3 @@ rules: settings: react: version: detect -overrides: - - files: - - scripts/**/*.mjs - env: - node: true diff --git a/README.md b/README.md index 084cdb62..9f2e8cef 100644 --- a/README.md +++ b/README.md @@ -49,16 +49,6 @@ If your Lightspeed service is running locally on `http://localhost:8080`, the `start-console.sh` script includes a proxy configuration that routes requests through the console, avoiding CORS issues. -`start-console.sh` uses `quay.io/openshift/origin-console:4.22` by default to -match this plugin's SDK version. If the Lightspeed chat icon is missing, check -the browser console for plugin errors and try pinning the image explicitly: -`CONSOLE_TAG=4.22 npm run start-console`. - -**kind / plain Kubernetes:** kubeconfigs often use client certificates instead of -bearer tokens. Use `npm run start-console-kind` (or -`ALLOW_DEV_TOKEN_MINT=1 npm run start-console`) to mint a temporary dev -ServiceAccount token. This creates cluster-admin credentials for local use only. - #### Running start-console with Apple silicon and podman If you are using podman on a Mac with Apple silicon, `npm run start-console` diff --git a/package.json b/package.json index b0f1bca0..38d4e096 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,7 @@ "build": "npm run clean && NODE_ENV=production npm run ts-node node_modules/.bin/webpack", "build-dev": "npm run clean && npm run ts-node node_modules/.bin/webpack", "start": "npm run ts-node node_modules/.bin/webpack serve", - "start-mock-ols": "node scripts/mock-ols-server.mjs", "start-console": "./start-console.sh", - "start-console-kind": "ALLOW_DEV_TOKEN_MINT=1 ./start-console.sh", "i18n": "./i18n-scripts/build-i18n.sh && node ./i18n-scripts/set-english-defaults.js", "ts-node": "ts-node -O '{\"module\":\"commonjs\"}'", "lint": "eslint ./*.{js,ts} ./src ./tests ./unit-tests && stylelint \"src/**/*.css\" --allow-empty-input", diff --git a/scripts/mock-ols-server.mjs b/scripts/mock-ols-server.mjs deleted file mode 100755 index d34106ce..00000000 --- a/scripts/mock-ols-server.mjs +++ /dev/null @@ -1,1034 +0,0 @@ -#!/usr/bin/env node -/** - * Minimal mock OpenShift Lightspeed API for local linked-resources testing. - * - * Mock MCP tool output and SSE events align with kubernetes-mcp-server - * (list_output=table, projects_list YAML, nodes_top metrics) and - * lightspeed-service streaming (tool_result/end event shapes). - * - * Usage (three terminals): - * 1. npm run start-mock-ols - * 2. npm run start - * 3. oc login && npm run start-console - * - * No OpenShift cluster? Use a local kind cluster: - * kind create cluster - * npm run start-console-kind # mints a dev token (ALLOW_DEV_TOKEN_MINT=1) - * - * Then open http://localhost:9000, open Lightspeed, and try e.g.: - * - "show pods" → pods in MOCK_NAMESPACE - * - "list projects" → projects_list - * - "list namespaces" → namespaces_list - * - "list configmaps" → resources_list (ConfigMap) - * - "list secrets" → resources_list (Secret) - * - "list services" → resources_list (Service) - * - "list deployments" → resources_list (Deployment) - * - "list statefulsets" → resources_list (StatefulSet) - * - "list routes" → resources_list (Route) - * - "show nodes" → nodes_top - * - "check deployment foo" → resources_get (single Deployment) - * - * Resource kinds mirror unit-tests/fixtures/k8sModels.ts (minus Pod/Namespace/Node - * which use dedicated scenarios). Each list kind supports MOCK_ env override. - * - * Environment: - * OLS_PORT Port (default: 8080) - * MOCK_OLS_SCENARIO Force scenario (see startup log for keys) - * MOCK_NAMESPACE Namespace for namespaced resources (default: default) - * MOCK_PODS Comma-separated pod names - * MOCK_POD_LIMIT Max pods from cluster (default: all; set e.g. 12 to cap) - * MOCK_DIVERSE_POD_STATUSES Set to 1 or true to cycle demo statuses in tool output (default: off) - * MOCK_LIST_LIMIT Max names for other list resources (default: 4) - * MOCK_DEPLOYMENT Deployment name for single-deployment scenario - * MOCK_DEPLOYMENTS Comma-separated deployment names (list scenario) - * MOCK_PROJECTS Comma-separated project names - * MOCK_NAMESPACES Comma-separated namespace names - * MOCK_NODES Comma-separated node names - * MOCK_ Comma-separated names per list resource (e.g. MOCK_SECRETS) - */ - -import { createServer } from 'node:http'; -import { execFileSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; - -const PORT = Number(process.env.OLS_PORT ?? 8080); -const MOCK_NAMESPACE = process.env.MOCK_NAMESPACE ?? 'default'; - -/** 0 or unset means no cap when fetching names from the cluster via oc. */ -const parseListLimit = (value, fallback) => { - if (value === undefined || value === '') { - return fallback; - } - const parsed = Number(value); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; -}; - -const DEFAULT_LIST_LIMIT = parseListLimit(process.env.MOCK_LIST_LIMIT, 4); -const POD_LIST_LIMIT = parseListLimit(process.env.MOCK_POD_LIMIT, 0); -const MOCK_DIVERSE_POD_STATUSES = - process.env.MOCK_DIVERSE_POD_STATUSES === '1' || - process.env.MOCK_DIVERSE_POD_STATUSES === 'true'; - -/** Demo statuses for MCP table + assistant prose (icons still come from live cluster watch). */ -const DIVERSE_POD_STATUS_PRESETS = [ - { ready: '1/1', status: 'Running', restarts: '0' }, - { ready: '0/1', status: 'Pending', restarts: '0' }, - { ready: '0/1', status: 'ContainerCreating', restarts: '0' }, - { ready: '0/1', status: 'CrashLoopBackOff', restarts: '12' }, - { ready: '0/1', status: 'ImagePullBackOff', restarts: '0' }, - { ready: '0/1', status: 'Error', restarts: '3' }, - { ready: '0/1', status: 'Failed', restarts: '0' }, - { ready: '1/1', status: 'Succeeded', restarts: '0' }, -]; - -const PLACEHOLDER_POD_NAMES = [ - 'mock-pod-running', - 'mock-pod-pending', - 'mock-pod-crash', - 'mock-pod-imagepull', - 'mock-pod-error', - 'mock-pod-failed', -]; - -const splitEnvList = (value) => - value - ?.split(',') - .map((entry) => entry.trim()) - .filter(Boolean) ?? []; - -const tryOc = (args) => { - try { - return execFileSync('oc', args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 10_000, - }).trim(); - } catch { - return null; - } -}; - -const tryOcJson = (args) => { - try { - return JSON.parse( - execFileSync('oc', args, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 10_000, - }), - ); - } catch { - return null; - } -}; - -const podReadyString = (pod) => { - const containers = pod.status?.containerStatuses ?? []; - if (containers.length === 0) { - return '0/1'; - } - const ready = containers.filter((container) => container.ready).length; - return `${ready}/${containers.length}`; -}; - -const podRestarts = (pod) => - (pod.status?.containerStatuses ?? []).reduce( - (sum, container) => sum + (container.restartCount ?? 0), - 0, - ); - -const podDisplayStatus = (pod) => { - const phase = pod.status?.phase ?? 'Unknown'; - for (const container of pod.status?.containerStatuses ?? []) { - const waitingReason = container.state?.waiting?.reason; - if (waitingReason) { - return waitingReason; - } - const terminatedReason = container.state?.terminated?.reason; - if (terminatedReason && phase !== 'Running' && phase !== 'Succeeded') { - return terminatedReason; - } - } - return phase; -}; - -const snapshotFromPod = (pod, index) => ({ - name: pod.metadata?.name ?? `pod-${index}`, - ready: podReadyString(pod), - status: podDisplayStatus(pod), - restarts: String(podRestarts(pod)), - age: '5m', - ip: pod.status?.podIP ?? '', - node: pod.spec?.nodeName ?? '', -}); - -const snapshotWithPreset = (name, index, useDiverseStatuses) => { - const preset = useDiverseStatuses - ? DIVERSE_POD_STATUS_PRESETS[index % DIVERSE_POD_STATUS_PRESETS.length] - : DIVERSE_POD_STATUS_PRESETS[0]; - return { - name, - ready: preset.ready, - status: preset.status, - restarts: preset.restarts, - age: '5m', - ip: `10.0.0.${index + 1}`, - node: 'worker-1', - }; -}; - -const resolvePodSnapshots = (namespace) => { - const fromEnv = splitEnvList(process.env.MOCK_PODS); - if (fromEnv.length > 0) { - return fromEnv.map((name, index) => - snapshotWithPreset(name, index, MOCK_DIVERSE_POD_STATUSES), - ); - } - - const list = tryOcJson(['get', 'pods', '-n', namespace, '-o', 'json']); - if (list?.items?.length > 0) { - const items = - POD_LIST_LIMIT > 0 ? list.items.slice(0, POD_LIST_LIMIT) : list.items; - const snapshots = items.map(snapshotFromPod); - if (MOCK_DIVERSE_POD_STATUSES) { - return snapshots.map((snapshot, index) => ({ - ...snapshot, - ...DIVERSE_POD_STATUS_PRESETS[index % DIVERSE_POD_STATUS_PRESETS.length], - name: snapshot.name, - ip: snapshot.ip, - node: snapshot.node, - })); - } - return snapshots; - } - - return PLACEHOLDER_POD_NAMES.map((name, index) => - snapshotWithPreset(name, index, MOCK_DIVERSE_POD_STATUSES), - ); -}; - -const resolveNames = ({ envVar, ocArgs, ocFallbackArgs, placeholders, limit = DEFAULT_LIST_LIMIT }) => { - const fromEnv = splitEnvList(process.env[envVar]); - if (fromEnv.length > 0) { - return fromEnv; - } - const fromCluster = tryOc(ocArgs) || (ocFallbackArgs ? tryOc(ocFallbackArgs) : null); - if (fromCluster) { - const allNames = fromCluster.split(/\s+/).filter(Boolean); - const names = limit > 0 ? allNames.slice(0, limit) : allNames; - if (names.length > 0) { - return names; - } - } - return placeholders; -}; - -const envVarForPlural = (plural) => `MOCK_${plural.replace(/-/g, '_').toUpperCase()}`; - -const listedResourceNames = (names) => names.join('\n'); - -const defaultListTokens = (kindLabel, { namespace, names, primary, namespaced }) => { - const count = names.length; - const plural = count === 1 ? kindLabel : `${kindLabel}s`; - const intro = namespaced - ? `I found ${count} ${plural} in the **${namespace}** namespace.\n\n` - : `I found ${count} ${plural} in this cluster.\n\n`; - return [ - intro, - names.map((name) => `**${name}**`).join('\n'), - `\n\n**${primary}** is a good place to start if you need more detail.`, - ]; -}; - -const workloadTable = - (apiVersion, kind) => - (namespace, names) => - names - .map((name) => `${namespace} ${apiVersion} ${kind} ${name} 1/1 1 1 5m app=mock`) - .join('\n'); - -const WORKLOAD_TABLE_HEADER = - 'NAMESPACE APIVERSION KIND NAME READY UP-TO-DATE AVAILABLE AGE LABELS'; - -const POD_TABLE_HEADER = - 'NAMESPACE APIVERSION KIND NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES LABELS'; - -/** - * List-resource definitions aligned with unit-tests/fixtures/k8sModels.ts. - * Pods, Namespaces, Nodes, and Projects use dedicated scenarios below. - */ -const LIST_RESOURCE_DEFS = [ - { - key: 'configmaps', - kind: 'ConfigMap', - plural: 'configmaps', - namespaced: true, - apiVersion: 'v1', - placeholders: ['kube-root-ca.crt', 'mock-app-config'], - detect: (q) => /\bconfigmaps?\b/.test(q) || /\bconfig maps?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME DATA AGE LABELS', - buildTable: (namespace, names) => - names - .map((name, index) => `${namespace} v1 ConfigMap ${name} ${index + 1} 5m app=mock`) - .join('\n'), - }, - { - key: 'secrets', - kind: 'Secret', - plural: 'secrets', - namespaced: true, - apiVersion: 'v1', - placeholders: ['builder-dockercfg-mock', 'mock-tls'], - detect: (q) => /\bsecrets?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME TYPE DATA AGE LABELS', - buildTable: (namespace, names) => - names.map((name) => `${namespace} v1 Secret ${name} Opaque 2 5m `).join('\n'), - }, - { - key: 'serviceaccounts', - kind: 'ServiceAccount', - plural: 'serviceaccounts', - namespaced: true, - apiVersion: 'v1', - placeholders: ['default', 'builder'], - detect: (q) => /\bserviceaccounts?\b/.test(q) || /\bservice accounts?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME SECRETS AGE LABELS', - buildTable: (namespace, names) => - names.map((name) => `${namespace} v1 ServiceAccount ${name} 1 5m `).join('\n'), - }, - { - key: 'services', - kind: 'Service', - plural: 'services', - namespaced: true, - apiVersion: 'v1', - placeholders: ['kubernetes', 'mock-api'], - detect: (q) => /\bservices?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR', - buildTable: (namespace, names) => - names - .map( - (name, index) => - `${namespace} v1 Service ${name} ClusterIP 10.96.${index + 1}.1 80/TCP 5m app=mock`, - ) - .join('\n'), - }, - { - key: 'deployments', - kind: 'Deployment', - plural: 'deployments', - namespaced: true, - apiVersion: 'apps/v1', - placeholders: ['mock-deployment-a', 'mock-deployment-b'], - detect: (q) => /\bdeployments\b/.test(q) || /\blist deployments?\b/.test(q), - tableHeader: WORKLOAD_TABLE_HEADER, - buildTable: workloadTable('apps/v1', 'Deployment'), - }, - { - key: 'statefulsets', - kind: 'StatefulSet', - plural: 'statefulsets', - namespaced: true, - apiVersion: 'apps/v1', - placeholders: ['mock-statefulset-a', 'mock-statefulset-b'], - detect: (q) => /\bstatefulsets?\b/.test(q) || /\bstateful sets?\b/.test(q), - tableHeader: WORKLOAD_TABLE_HEADER, - buildTable: workloadTable('apps/v1', 'StatefulSet'), - }, - { - key: 'daemonsets', - kind: 'DaemonSet', - plural: 'daemonsets', - namespaced: true, - apiVersion: 'apps/v1', - placeholders: ['mock-daemonset-a'], - detect: (q) => /\bdaemonsets?\b/.test(q) || /\bdaemon sets?\b/.test(q), - tableHeader: WORKLOAD_TABLE_HEADER, - buildTable: workloadTable('apps/v1', 'DaemonSet'), - }, - { - key: 'replicasets', - kind: 'ReplicaSet', - plural: 'replicasets', - namespaced: true, - apiVersion: 'apps/v1', - placeholders: ['mock-replicaset-a'], - detect: (q) => /\breplicasets?\b/.test(q) || /\breplica sets?\b/.test(q), - tableHeader: WORKLOAD_TABLE_HEADER, - buildTable: workloadTable('apps/v1', 'ReplicaSet'), - }, - { - key: 'replicationcontrollers', - kind: 'ReplicationController', - plural: 'replicationcontrollers', - namespaced: true, - apiVersion: 'v1', - placeholders: ['mock-rc-a'], - detect: (q) => - /\breplicationcontrollers?\b/.test(q) || /\breplication controllers?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME DESIRED CURRENT READY AGE SELECTOR LABELS', - buildTable: (namespace, names) => - names - .map((name) => `${namespace} v1 ReplicationController ${name} 1 1 1 5m app=mock `) - .join('\n'), - }, - { - key: 'deploymentconfigs', - kind: 'DeploymentConfig', - plural: 'deploymentconfigs', - namespaced: true, - apiVersion: 'apps.openshift.io/v1', - placeholders: ['mock-dc-a', 'mock-dc-b'], - detect: (q) => - /\bdeploymentconfigs?\b/.test(q) || /\bdeployment configs?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME REVISION DESIRED CURRENT TRIGGERED BY LABELS', - buildTable: (namespace, names) => - names - .map( - (name) => - `${namespace} apps.openshift.io/v1 DeploymentConfig ${name} 1 1 1 config `, - ) - .join('\n'), - }, - { - key: 'jobs', - kind: 'Job', - plural: 'jobs', - namespaced: true, - apiVersion: 'batch/v1', - placeholders: ['mock-job-a'], - detect: (q) => /\bjobs?\b/.test(q) && !/\bcronjobs?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME COMPLETIONS DURATION AGE LABELS', - buildTable: (namespace, names) => - names.map((name) => `${namespace} batch/v1 Job ${name} 1/1 12s 5m `).join('\n'), - }, - { - key: 'cronjobs', - kind: 'CronJob', - plural: 'cronjobs', - namespaced: true, - apiVersion: 'batch/v1', - placeholders: ['mock-cronjob-a'], - detect: (q) => /\bcronjobs?\b/.test(q) || /\bcron jobs?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE AGE LABELS', - buildTable: (namespace, names) => - names - .map((name) => `${namespace} batch/v1 CronJob ${name} */5 * * * * False 0 5m `) - .join('\n'), - }, - { - key: 'ingresses', - kind: 'Ingress', - plural: 'ingresses', - namespaced: true, - apiVersion: 'networking.k8s.io/v1', - placeholders: ['mock-ingress'], - detect: (q) => /\bingresses?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME CLASS HOSTS ADDRESS PORTS AGE LABELS', - buildTable: (namespace, names) => - names - .map( - (name, index) => - `${namespace} networking.k8s.io/v1 Ingress ${name} openshift-default mock-${index + 1}.apps.example.com 10.0.0.1 80 5m `, - ) - .join('\n'), - }, - { - key: 'routes', - kind: 'Route', - plural: 'routes', - namespaced: true, - apiVersion: 'route.openshift.io/v1', - placeholders: ['mock-route'], - detect: (q) => /\broutes?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME AGE LABELS', - buildTable: (namespace, names) => - names - .map((name) => `${namespace} route.openshift.io/v1 Route ${name} 5m `) - .join('\n'), - }, - { - key: 'persistentvolumeclaims', - kind: 'PersistentVolumeClaim', - plural: 'persistentvolumeclaims', - namespaced: true, - apiVersion: 'v1', - placeholders: ['mock-pvc-a'], - detect: (q) => - /\bpersistentvolumeclaims?\b/.test(q) || - /\bpersistent volume claims?\b/.test(q) || - /\bpvcs?\b/.test(q), - tableHeader: - 'NAMESPACE APIVERSION KIND NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE LABELS', - buildTable: (namespace, names) => - names - .map( - (name) => - `${namespace} v1 PersistentVolumeClaim ${name} Bound pv-mock 1Gi RWO mock-sc 5m `, - ) - .join('\n'), - }, - { - key: 'persistentvolumes', - kind: 'PersistentVolume', - plural: 'persistentvolumes', - namespaced: false, - apiVersion: 'v1', - placeholders: ['pv-mock-a'], - detect: (q) => - /\bpersistentvolumes?\b/.test(q) || - /\bpersistent volumes?\b/.test(q) || - (/\bpvs?\b/.test(q) && !/\bpvcs?\b/.test(q)), - tableHeader: 'APIVERSION KIND NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS', - buildTable: (_namespace, names) => - names - .map((name) => `v1 PersistentVolume ${name} 1Gi RWO Delete Bound default/${name} mock-sc`) - .join('\n'), - }, - { - key: 'horizontalpodautoscalers', - kind: 'HorizontalPodAutoscaler', - plural: 'horizontalpodautoscalers', - namespaced: true, - apiVersion: 'autoscaling/v2', - placeholders: ['mock-hpa'], - detect: (q) => - /\bhorizontalpodautoscalers?\b/.test(q) || - /\bhorizontal pod autoscalers?\b/.test(q) || - /\bhpas?\b/.test(q), - tableHeader: - 'NAMESPACE APIVERSION KIND NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE LABELS', - buildTable: (namespace, names) => - names - .map( - (name) => - `${namespace} autoscaling/v2 HorizontalPodAutoscaler ${name} Deployment/mock-deployment 50%/60% 1 5 2 5m `, - ) - .join('\n'), - }, - { - key: 'subscriptions', - kind: 'Subscription', - plural: 'subscriptions', - namespaced: true, - apiVersion: 'operators.coreos.com/v1alpha1', - placeholders: ['openshift-operators-redhat'], - detect: (q) => /\bsubscriptions?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME PACKAGE SOURCE CHANNEL LABELS', - buildTable: (namespace, names) => - names - .map( - (name) => - `${namespace} operators.coreos.com/v1alpha1 Subscription ${name} netobserv-operator redhat-operators stable `, - ) - .join('\n'), - }, - { - key: 'virtualmachines', - kind: 'VirtualMachine', - plural: 'virtualmachines', - namespaced: true, - apiVersion: 'kubevirt.io/v1', - placeholders: ['mock-vm-a'], - detect: (q) => - /\bvirtualmachines?\b/.test(q) || - /\bvirtual machines?\b/.test(q) || - (/\bvms?\b/.test(q) && !/\bhpas?\b/.test(q)), - tableHeader: 'NAMESPACE APIVERSION KIND NAME AGE STATUS READY LABELS', - buildTable: (namespace, names) => - names - .map((name) => `${namespace} kubevirt.io/v1 VirtualMachine ${name} 5m Running True `) - .join('\n'), - }, - { - key: 'flowcollectors', - kind: 'FlowCollector', - plural: 'flowcollectors', - namespaced: false, - apiVersion: 'flows.netobserv.io/v1beta2', - placeholders: ['cluster'], - detect: (q) => /\bflowcollectors?\b/.test(q) || /\bflow collectors?\b/.test(q), - tableHeader: 'APIVERSION KIND NAME STATUS AGE', - buildTable: (_namespace, names) => - names.map((name) => `flows.netobserv.io/v1beta2 FlowCollector ${name} Ready 5m`).join('\n'), - }, - { - key: 'flowcollectorslices', - kind: 'FlowCollectorSlice', - plural: 'flowcollectorslices', - namespaced: true, - apiVersion: 'flows.netobserv.io/v1beta2', - placeholders: ['workers'], - detect: (q) => /\bflowcollectorslices?\b/.test(q) || /\bflow collector slices?\b/.test(q), - tableHeader: 'NAMESPACE APIVERSION KIND NAME STATUS AGE LABELS', - buildTable: (namespace, names) => - names - .map( - (name) => - `${namespace} flows.netobserv.io/v1beta2 FlowCollectorSlice ${name} Ready 5m `, - ) - .join('\n'), - }, - { - key: 'clusterversions', - kind: 'ClusterVersion', - plural: 'clusterversions', - namespaced: false, - apiVersion: 'config.openshift.io/v1', - placeholders: ['version'], - detect: (q) => /\bclusterversions?\b/.test(q) || /\bcluster versions?\b/.test(q), - tableHeader: 'APIVERSION KIND NAME VERSION AVAILABLE PROGRESSING DEGRADED SINCE', - buildTable: (_namespace, names) => - names - .map((name) => `config.openshift.io/v1 ClusterVersion ${name} 4.16.0 True False False 5m`) - .join('\n'), - }, - { - key: 'machineconfigpools', - kind: 'MachineConfigPool', - plural: 'machineconfigpools', - namespaced: false, - apiVersion: 'machineconfiguration.openshift.io/v1', - placeholders: ['worker', 'master'], - detect: (q) => - /\bmachineconfigpools?\b/.test(q) || /\bmachine config pools?\b/.test(q), - tableHeader: 'APIVERSION KIND NAME CONFIG UPDATED UPDATING DEGRADED MACHINECOUNT', - buildTable: (_namespace, names) => - names - .map( - (name) => - `machineconfiguration.openshift.io/v1 MachineConfigPool ${name} rendered-${name} True False False 3`, - ) - .join('\n'), - }, - { - key: 'ingresscontrollers', - kind: 'IngressController', - plural: 'ingresscontrollers', - namespaced: false, - apiVersion: 'operator.openshift.io/v1', - placeholders: ['default'], - detect: (q) => - /\bingresscontrollers?\b/.test(q) || /\bingress controllers?\b/.test(q), - tableHeader: 'APIVERSION KIND NAME DOMAIN AVAILABLE PROGRESSING DEGRADED', - buildTable: (_namespace, names) => - names - .map( - (name) => - `operator.openshift.io/v1 IngressController ${name} apps.example.com True False False`, - ) - .join('\n'), - }, -]; - -const listResourceRuntime = LIST_RESOURCE_DEFS.map((def) => { - const envVar = envVarForPlural(def.plural); - const ocArgs = def.namespaced - ? ['get', def.plural, '-n', MOCK_NAMESPACE, '-o', 'jsonpath={.items[*].metadata.name}'] - : ['get', def.plural, '-o', 'jsonpath={.items[*].metadata.name}']; - - const names = resolveNames({ - envVar, - ocArgs, - placeholders: def.placeholders, - limit: def.limit ?? DEFAULT_LIST_LIMIT, - }); - - const scenario = () => { - const namespace = MOCK_NAMESPACE; - const primary = names[0]; - const toolArgs = def.namespaced - ? { apiVersion: def.apiVersion, kind: def.kind, namespace } - : { apiVersion: def.apiVersion, kind: def.kind }; - const formatTokens = def.formatTokens ?? defaultListTokens; - return { - toolName: 'resources_list', - toolArgs, - toolContent: `${def.tableHeader}\n${def.buildTable(def.namespaced ? namespace : undefined, names)}`, - tokens: formatTokens(def.kind, { - namespace, - names, - primary, - namespaced: def.namespaced, - }), - }; - }; - - return { ...def, envVar, names, scenario, rule: { key: def.key, test: def.detect } }; -}); - -const POD_SNAPSHOTS = resolvePodSnapshots(MOCK_NAMESPACE); -const POD_NAMES = POD_SNAPSHOTS.map((pod) => pod.name); - -const DEPLOYMENT_NAME = (() => { - if (process.env.MOCK_DEPLOYMENT) { - return process.env.MOCK_DEPLOYMENT; - } - return ( - tryOc(['get', 'deployments', '-n', MOCK_NAMESPACE, '-o', 'jsonpath={.items[0].metadata.name}']) || - 'mock-deployment' - ); -})(); - -const PROJECT_NAMES = resolveNames({ - envVar: 'MOCK_PROJECTS', - ocArgs: ['get', 'projects', '-o', 'jsonpath={.items[*].metadata.name}'], - ocFallbackArgs: ['get', 'namespaces', '-o', 'jsonpath={.items[*].metadata.name}'], - placeholders: ['default', 'kube-system'], - limit: DEFAULT_LIST_LIMIT, -}); - -const NAMESPACE_NAMES = resolveNames({ - envVar: 'MOCK_NAMESPACES', - ocArgs: ['get', 'namespaces', '-o', 'jsonpath={.items[*].metadata.name}'], - placeholders: ['default', 'kube-system', 'kube-public'], - limit: DEFAULT_LIST_LIMIT, -}); - -const NODE_NAMES = resolveNames({ - envVar: 'MOCK_NODES', - ocArgs: ['get', 'nodes', '-o', 'jsonpath={.items[*].metadata.name}'], - placeholders: ['worker-1.example.internal', 'worker-2.example.internal'], - limit: DEFAULT_LIST_LIMIT, -}); - -const sseLine = (event, data) => `data: ${JSON.stringify({ event, data })}\n\n`; - -const buildPodsTable = (namespace, pods) => - pods - .map( - (pod) => - `${namespace} v1 Pod ${pod.name} ${pod.ready} ${pod.status} ${pod.restarts} ${pod.age} ${pod.ip} ${pod.node} app=mock`, - ) - .join('\n'); - -const buildProjectsYaml = (projects) => - projects - .map( - (name) => `- apiVersion: project.openshift.io/v1 - kind: Project - metadata: - name: ${name} - status: - phase: Active`, - ) - .join('\n'); - -const buildDeploymentYaml = (namespace, name) => `apiVersion: apps/v1 -kind: Deployment -metadata: - name: ${name} - namespace: ${namespace} - labels: - app: mock -spec: - replicas: 1 - selector: - matchLabels: - app: mock - template: - metadata: - labels: - app: mock - spec: - containers: - - name: mock - image: registry/mock/${name}:latest -status: - replicas: 1 - readyReplicas: 1 - availableReplicas: 1`; - -const buildNamespacesTable = (namespaces) => - namespaces - .map((name) => `v1 Namespace ${name} Active 3h38m kubernetes.io/metadata.name=${name}`) - .join('\n'); - -const buildNodesTopTable = (nodeNames) => - [ - 'NAME CPU(cores) MEMORY(bytes)', - ...nodeNames.map((name, index) => `${name} ${100 + index * 10}m ${3000 + index * 100}Mi`), - ].join('\n'); - -/** Markdown hard line break (two trailing spaces before newline). */ -const mdLine = (line) => `${line} `; - -const labeledPodBlock = (pod) => - [ - mdLine(`Name: ${pod.name}`), - mdLine(`Ready: ${pod.ready}`), - mdLine(`Status: ${pod.status}`), - mdLine(`Restarts: ${pod.restarts}`), - mdLine(`Age: ${pod.age}`), - ].join('\n'); - -const verticalPodListing = (pods) => pods.map(labeledPodBlock).join('\n\n'); - -const labeledNodeBlock = (name, index) => - [ - mdLine(`Name: ${name}`), - mdLine(`CPU: ${100 + index * 10}m`), - mdLine(`Memory: ${3000 + index * 100}Mi`), - ].join('\n'); - -const verticalNodeListing = (nodeNames) => nodeNames.map(labeledNodeBlock).join('\n\n'); - -const scenarios = Object.fromEntries( - listResourceRuntime.map(({ key, scenario }) => [key, scenario]), -); - -Object.assign(scenarios, { - pods: () => { - const namespace = MOCK_NAMESPACE; - const primaryPod = POD_NAMES[0]; - const count = POD_NAMES.length; - return { - toolName: 'pods_list_in_namespace', - toolArgs: { namespace }, - toolContent: `${POD_TABLE_HEADER}\n${buildPodsTable(namespace, POD_SNAPSHOTS)}`, - tokens: [ - `I found ${count} pod${count === 1 ? '' : 's'} in the **${namespace}** namespace.\n\n`, - verticalPodListing(POD_SNAPSHOTS), - `\n\nPod **${primaryPod}** is a good place to start troubleshooting.`, - ], - }; - }, - projects: () => { - const primary = PROJECT_NAMES[0]; - const count = PROJECT_NAMES.length; - return { - toolName: 'projects_list', - toolArgs: {}, - toolContent: buildProjectsYaml(PROJECT_NAMES), - tokens: [ - `I found ${count} project${count === 1 ? '' : 's'} on this cluster.\n\n`, - PROJECT_NAMES.map((name) => `**${name}**`).join('\n'), - `\n\nProject **${primary}** is a common starting point.`, - ], - }; - }, - deployment: () => { - const namespace = MOCK_NAMESPACE; - const name = DEPLOYMENT_NAME; - return { - toolName: 'resources_get', - toolArgs: { apiVersion: 'apps/v1', kind: 'Deployment', name, namespace }, - toolContent: buildDeploymentYaml(namespace, name), - tokens: [ - `Deployment **${name}** in the **${namespace}** namespace may need attention.\n\n`, - `Open **${name}** in the console to inspect replica status and rollout history.`, - ], - }; - }, - namespaces: () => { - const primary = NAMESPACE_NAMES[0]; - const count = NAMESPACE_NAMES.length; - return { - toolName: 'namespaces_list', - toolArgs: {}, - toolContent: `APIVERSION KIND NAME STATUS AGE LABELS\n${buildNamespacesTable(NAMESPACE_NAMES)}`, - tokens: [ - `I found ${count} namespace${count === 1 ? '' : 's'} in this cluster.\n\n`, - listedResourceNames(NAMESPACE_NAMES), - `\n\nNamespace **${primary}** is often used for application workloads.`, - ], - }; - }, - nodes: () => { - const primary = NODE_NAMES[0]; - const count = NODE_NAMES.length; - return { - toolName: 'nodes_top', - toolArgs: {}, - toolContent: buildNodesTopTable(NODE_NAMES), - tokens: [ - `Here are ${count} node${count === 1 ? '' : 's'} with current CPU and memory usage:\n\n`, - verticalNodeListing(NODE_NAMES), - `\n\nNode **${primary}** has the most relevant metrics right now.`, - ], - }; - }, -}); - -const SCENARIO_HINTS = Object.keys(scenarios).join(' | '); - -/** Strip console page context prepended by the plugin (`Context: …\n\n`). */ -const extractUserQuery = (query) => { - const normalized = (query ?? '').trim(); - const match = normalized.match(/^context:\s*[\s\S]*?\n\n([\s\S]*)$/i); - return (match?.[1] ?? normalized).trim(); -}; - -const SCENARIO_RULES = [ - ...listResourceRuntime.map(({ rule }) => rule), - { - key: 'namespaces', - test: (q) => /\bnamespaces?\b/.test(q) && !/\bpods?\b/.test(q), - }, - { - key: 'nodes', - test: (q) => /\bnodes?\b/.test(q), - }, - { - key: 'projects', - test: (q) => /\bprojects?\b/.test(q), - }, - { - key: 'deployment', - test: (q) => /\bdeployment\b/.test(q) || /\bdeploy\b/.test(q), - }, - { - key: 'pods', - test: (q) => /\bpods?\b/.test(q), - }, -]; - -const detectScenario = (rawQuery) => { - const forced = process.env.MOCK_OLS_SCENARIO?.toLowerCase(); - if (forced && scenarios[forced]) { - return forced; - } - - const userQuery = extractUserQuery(rawQuery).toLowerCase(); - const matched = SCENARIO_RULES.find((rule) => rule.test(userQuery)); - return matched?.key ?? 'pods'; -}; - -const buildStreamBody = (query) => { - const scenarioKey = detectScenario(query); - const scenario = scenarios[scenarioKey](); - const conversationId = randomUUID(); - const toolId = `tool-${scenarioKey}-1`; - const lines = [ - sseLine('start', { conversation_id: conversationId }), - sseLine('tool_call', { id: toolId, name: scenario.toolName, args: scenario.toolArgs }), - sseLine('tool_result', { - id: toolId, - name: scenario.toolName, - type: 'tool_result', - round: 1, - content: scenario.toolContent, - status: 'success', - }), - ]; - - scenario.tokens.forEach((token, index) => { - lines.push(sseLine('token', { id: index, token })); - }); - - lines.push( - sseLine('end', { - referenced_documents: [], - truncated: false, - input_tokens: 120, - output_tokens: 80, - reasoning_tokens: 0, - }), - ); - return { body: lines.join(''), scenarioKey, conversationId }; -}; - -const readBody = (req) => - new Promise((resolve, reject) => { - const chunks = []; - req.on('data', (chunk) => chunks.push(chunk)); - req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); - req.on('error', reject); - }); - -const json = (res, status, payload) => { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(payload)); -}; - -const server = createServer(async (req, res) => { - const { method, url } = req; - - if (method === 'GET' && url === '/readiness') { - return json(res, 200, { ready: true }); - } - - if (method === 'POST' && url === '/authorized') { - return json(res, 200, { user_id: 'mock-user', username: 'mock-user' }); - } - - if (method === 'GET' && url === '/v1/feedback/status') { - return json(res, 200, { status: { enabled: true } }); - } - - if (method === 'POST' && url === '/v1/feedback') { - return json(res, 200, { message: 'Feedback received' }); - } - - if (method === 'POST' && url === '/v1/streaming_query') { - let query = ''; - try { - const raw = await readBody(req); - const body = raw ? JSON.parse(raw) : {}; - query = body.query ?? ''; - } catch { - // ignore parse errors; still return a mock stream - } - - const { body, scenarioKey } = buildStreamBody(query); - // eslint-disable-next-line no-console - console.log(`[mock-ols] streaming_query scenario=${scenarioKey} query=${JSON.stringify(query)}`); - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }); - res.end(body); - return; - } - - // eslint-disable-next-line no-console - console.log(`[mock-ols] ${method} ${url} → 404`); - json(res, 404, { detail: `Not found: ${method} ${url}` }); -}); - -server.listen(PORT, () => { - // eslint-disable-next-line no-console - console.log(`Mock OLS listening on http://localhost:${PORT}`); - // eslint-disable-next-line no-console - console.log(` namespace: ${MOCK_NAMESPACE}`); - // eslint-disable-next-line no-console - console.log(` scenarios: ${SCENARIO_HINTS}`); - // eslint-disable-next-line no-console - console.log(` pods: ${POD_SNAPSHOTS.map((pod) => `${pod.name}(${pod.status})`).join(', ')}`); - if (MOCK_DIVERSE_POD_STATUSES) { - // eslint-disable-next-line no-console - console.log(' note: MOCK_DIVERSE_POD_STATUSES overrides tool-output status (icons use live watch)'); - } - // eslint-disable-next-line no-console - console.log(` projects: ${PROJECT_NAMES.join(', ')}`); - // eslint-disable-next-line no-console - console.log(` namespaces: ${NAMESPACE_NAMES.join(', ')}`); - // eslint-disable-next-line no-console - console.log(` nodes: ${NODE_NAMES.join(', ')}`); - for (const { key, names } of listResourceRuntime) { - // eslint-disable-next-line no-console - console.log(` ${key.padEnd(13)} ${names.join(', ')}`); - } - // eslint-disable-next-line no-console - console.log(''); - // eslint-disable-next-line no-console - console.log('Try in Lightspeed: "list secrets", "list routes", "list statefulsets", …'); - // eslint-disable-next-line no-console - console.log('Force a scenario: MOCK_OLS_SCENARIO=routes npm run start-mock-ols'); - // eslint-disable-next-line no-console - console.log(''); - // eslint-disable-next-line no-console - console.log('Next: npm run start (terminal 2)'); - // eslint-disable-next-line no-console - console.log(''); - // eslint-disable-next-line no-console - console.log('Then: oc login && npm run start-console (terminal 3)'); - // eslint-disable-next-line no-console - console.log(' or: kind create cluster && npm run start-console (no OpenShift needed)'); - // eslint-disable-next-line no-console - console.log(''); - // eslint-disable-next-line no-console - console.log('Finally: open http://localhost:9000'); -}); diff --git a/start-console.sh b/start-console.sh index b92d83c9..a75a264f 100755 --- a/start-console.sh +++ b/start-console.sh @@ -2,119 +2,28 @@ set -euo pipefail -# Match the plugin SDK (4.22+). origin-console:latest can lag behind and miss APIs -# such as useUserPreference, which prevents the Lightspeed button from rendering. -CONSOLE_TAG=${CONSOLE_TAG:=4.22} +CONSOLE_TAG=${CONSOLE_TAG:=latest} CONSOLE_IMAGE=${CONSOLE_IMAGE:="quay.io/openshift/origin-console:${CONSOLE_TAG}"} CONSOLE_PORT=${CONSOLE_PORT:=9000} OLS_PORT=${OLS_PORT:=8080} -CONSOLE_DEV_SA_NAME=${CONSOLE_DEV_SA_NAME:=lightspeed-console-dev} -CONSOLE_DEV_SA_NAMESPACE=${CONSOLE_DEV_SA_NAMESPACE:=kube-system} - -dev_token_mint_allowed() { - [ "${ALLOW_DEV_TOKEN_MINT:-}" = "1" ] || [ "${MOCK_ALLOW_DEV_TOKEN_MINT:-}" = "1" ] -} - -resolve_api_server() { - if command -v oc >/dev/null 2>&1 && oc whoami >/dev/null 2>&1; then - oc whoami --show-server - return - fi - if command -v kubectl >/dev/null 2>&1; then - kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}' - return - fi - return 1 -} - -resolve_bearer_token() { - local token user - - if command -v oc >/dev/null 2>&1; then - token=$(oc whoami --show-token 2>/dev/null || true) - if [ -n "$token" ]; then - echo "$token" - return - fi - fi - - if ! command -v kubectl >/dev/null 2>&1; then - return 1 - fi - - user=$(kubectl config view --minify -o jsonpath='{.users[0].name}' 2>/dev/null || true) - if [ -n "$user" ]; then - token=$(kubectl config view --raw -o jsonpath="{.users[?(@.name==\"${user}\")].user.token}" 2>/dev/null || true) - if [ -n "$token" ]; then - echo "$token" - return - fi - fi - - # Plain Kubernetes clusters (e.g. kind) often use client certs; mint a dev token instead. - if ! dev_token_mint_allowed; then - echo "error: no bearer token in kubeconfig (common with kind/minikube client-cert auth)." >&2 - echo " Run: ALLOW_DEV_TOKEN_MINT=1 npm run start-console" >&2 - echo " or: npm run start-console-kind" >&2 - echo " This creates a temporary cluster-admin ServiceAccount (${CONSOLE_DEV_SA_NAMESPACE}/${CONSOLE_DEV_SA_NAME})." >&2 - return 1 - fi - if ! kubectl get serviceaccount "$CONSOLE_DEV_SA_NAME" -n "$CONSOLE_DEV_SA_NAMESPACE" >/dev/null 2>&1; then - kubectl create serviceaccount "$CONSOLE_DEV_SA_NAME" -n "$CONSOLE_DEV_SA_NAMESPACE" >/dev/null - fi - if ! kubectl get clusterrolebinding "$CONSOLE_DEV_SA_NAME" >/dev/null 2>&1; then - kubectl create clusterrolebinding "$CONSOLE_DEV_SA_NAME" \ - --clusterrole=cluster-admin \ - --serviceaccount="${CONSOLE_DEV_SA_NAMESPACE}:${CONSOLE_DEV_SA_NAME}" >/dev/null - fi - kubectl -n "$CONSOLE_DEV_SA_NAMESPACE" create token "$CONSOLE_DEV_SA_NAME" --duration=24h -} echo "Starting local OpenShift console..." -if command -v oc >/dev/null 2>&1; then - PREFERRED_CLI=oc -elif command -v kubectl >/dev/null 2>&1; then - PREFERRED_CLI=kubectl -else - echo "error: neither oc nor kubectl found in PATH." >&2 - exit 1 -fi - -if [ "$PREFERRED_CLI" = oc ] && ! oc whoami >/dev/null 2>&1; then - echo "error: not logged in to a cluster. Run 'oc login' (or point KUBECONFIG at a running cluster) and retry." >&2 - exit 1 -fi - -if [ "$PREFERRED_CLI" = kubectl ] && ! kubectl cluster-info >/dev/null 2>&1; then - echo "error: cannot reach the Kubernetes API. Is your cluster running?" >&2 - echo " Try: kubectl cluster-info" >&2 - exit 1 -fi - -BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT=$(resolve_api_server) -BRIDGE_K8S_AUTH_BEARER_TOKEN=$(resolve_bearer_token || true) - -if [ -z "$BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT" ] || [ -z "$BRIDGE_K8S_AUTH_BEARER_TOKEN" ]; then - echo "error: could not read API server or bearer token." >&2 - echo " server: ${BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT:-}" >&2 - echo " OpenShift: run 'oc login', then 'oc whoami --show-token'." >&2 - echo " kind/minikube: npm run start-console-kind" >&2 - echo " (sets ALLOW_DEV_TOKEN_MINT=1 to mint a dev ServiceAccount token)" >&2 - exit 1 -fi - BRIDGE_USER_AUTH="disabled" BRIDGE_K8S_MODE="off-cluster" BRIDGE_K8S_AUTH="bearer-token" BRIDGE_K8S_MODE_OFF_CLUSTER_SKIP_VERIFY_TLS=true - -BRIDGE_USER_SETTINGS_LOCATION="localstorage" - +BRIDGE_K8S_MODE_OFF_CLUSTER_ENDPOINT=$(oc whoami --show-server) # The monitoring operator is not always installed (e.g. for local OpenShift). Tolerate missing config maps. set +e BRIDGE_K8S_MODE_OFF_CLUSTER_THANOS=$(oc -n openshift-config-managed get configmap monitoring-shared-config -o jsonpath='{.data.thanosPublicURL}' 2>/dev/null) BRIDGE_K8S_MODE_OFF_CLUSTER_ALERTMANAGER=$(oc -n openshift-config-managed get configmap monitoring-shared-config -o jsonpath='{.data.alertmanagerPublicURL}' 2>/dev/null) +set -e +BRIDGE_K8S_AUTH_BEARER_TOKEN=$(oc whoami --show-token 2>/dev/null) +BRIDGE_USER_SETTINGS_LOCATION="localstorage" + +# Don't fail if the cluster doesn't have gitops. +set +e GITOPS_HOSTNAME=$(oc -n openshift-gitops get route cluster -o jsonpath='{.spec.host}' 2>/dev/null) set -e if [ -n "$GITOPS_HOSTNAME" ]; then