Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/components/GeneralPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ import ImportAction from './ImportAction';
import NewChatModal from './NewChatModal';
import Prompt from './Prompt';
import ReadinessAlert from './ReadinessAlert';
import PromQLValue from './PromQLValue';
import QueryBrowserGraph from './QueryBrowserGraph';
import ResponseTools from './ResponseTools';
import WelcomeNotice from './WelcomeNotice';

Expand Down Expand Up @@ -94,7 +96,23 @@ const isURL = (s: string): boolean => {
}
};

const Code = ({ children }: { children?: React.ReactNode }) => {
type CodeProps = {
children?: React.ReactNode;
className?: string;
};

const Code: React.FC<CodeProps> = ({ children, className }) => {
const isPromQLInstant = className?.includes('language-promql-instant');
const isPromQL = className?.includes('language-promql') && !isPromQLInstant;

if (isPromQLInstant && children) {
return <PromQLValue query={String(children).trim()} />;
}

if (isPromQL && children) {
return <QueryBrowserGraph query={String(children).trim()} />;
}

if (!children || !String(children).includes('\n')) {
return <code>{children}</code>;
}
Expand Down
218 changes: 218 additions & 0 deletions src/components/PromQLValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import * as React from 'react';
import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk';
import { Spinner, TextArea } from '@patternfly/react-core';
import { debounce } from 'lodash';

const PROMETHEUS_BASE_PATH = '/api/prometheus';
const PROMETHEUS_TENANCY_BASE_PATH = '/api/prometheus-tenancy';
const POLL_INTERVAL = 15 * 1000;

type PrometheusResponse = {
status: string;
data: {
resultType: 'vector' | 'scalar' | 'matrix' | 'string';
result: Array<{
metric: Record<string, string>;
value: [number, string];
}>;
};
};

const formatValue = (value: string): string => {
const num = parseFloat(value);
if (isNaN(num)) {
return value;
}
// Format large numbers with appropriate units
if (Math.abs(num) >= 1e9) {
return `${(num / 1e9).toFixed(2)}B`;
}
if (Math.abs(num) >= 1e6) {
return `${(num / 1e6).toFixed(2)}M`;
}
if (Math.abs(num) >= 1e3) {
return `${(num / 1e3).toFixed(2)}K`;
}
// Format decimals nicely
if (num % 1 !== 0) {
return num.toFixed(4).replace(/\.?0+$/, '');
}
return num.toString();
};

const PromQLValue: React.FC<{ query: string }> = ({ query: initialQuery }) => {
const [inputValue, setInputValue] = React.useState('');
const [query, setQuery] = React.useState('');
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [results, setResults] = React.useState<
Array<{ metric: Record<string, string>; value: string }>
>([]);
const [updateKey, setUpdateKey] = React.useState(0);

const debouncedSetQuery = React.useMemo(
() =>
debounce((value: string) => {
setQuery(value);
}, 500),
[],
);

const effectiveQuery = inputValue || initialQuery;

React.useEffect(() => {
debouncedSetQuery(effectiveQuery);
}, [debouncedSetQuery, effectiveQuery]);

const fetchData = React.useCallback(
async (isInitialLoad: boolean) => {
if (isInitialLoad) {
setLoading(true);
}
setError(null);

const encodedQuery = encodeURIComponent(query);
const url = `${PROMETHEUS_BASE_PATH}/api/v1/query?query=${encodedQuery}`;

try {
const response: PrometheusResponse = await consoleFetchJSON(url);

if (response.status !== 'success') {
setError('Query failed');
return;
}

const data = response.data;
if (data.resultType === 'scalar') {
// Scalar result is [timestamp, value]
const scalarResult = data.result as unknown as [number, string];
setResults([{ metric: {}, value: scalarResult[1] }]);
setUpdateKey((k) => k + 1);
} else if (data.resultType === 'vector') {
setResults(
data.result.map((r) => ({
metric: r.metric,
value: r.value[1],
})),
);
setUpdateKey((k) => k + 1);
} else {
setError(`Unexpected result type: ${data.resultType}`);
}
} catch (err) {
// Try tenancy endpoint as fallback
try {
const tenancyUrl = `${PROMETHEUS_TENANCY_BASE_PATH}/api/v1/query?query=${encodedQuery}`;
const response: PrometheusResponse = await consoleFetchJSON(tenancyUrl);

if (response.status !== 'success') {
setError('Query failed');
return;
}

const data = response.data;
if (data.resultType === 'scalar') {
const scalarResult = data.result as unknown as [number, string];
setResults([{ metric: {}, value: scalarResult[1] }]);
setUpdateKey((k) => k + 1);
} else if (data.resultType === 'vector') {
setResults(
data.result.map((r) => ({
metric: r.metric,
value: r.value[1],
})),
);
setUpdateKey((k) => k + 1);
} else {
setError(`Unexpected result type: ${data.resultType}`);
}
} catch {
setError(err instanceof Error ? err.message : 'Failed to fetch data');
}
} finally {
if (isInitialLoad) {
setLoading(false);
}
}
},
[query],
);

React.useEffect(() => {
if (!query) {
return;
}

fetchData(true);

const intervalId = setInterval(() => {
fetchData(false);
}, POLL_INTERVAL);

return () => clearInterval(intervalId);
}, [fetchData, query]);

const onChange = React.useCallback(
(_event: React.ChangeEvent<HTMLTextAreaElement>, value: string) => {
setInputValue(value);
},
[],
);

const renderResults = () => {
if (loading) {
return <Spinner size="md" />;
}

if (error) {
return <span className="ols-plugin__promql-value-error">{error}</span>;
}

if (results.length === 0) {
return <span className="ols-plugin__promql-value-empty">No data</span>;
}

if (results.length === 1 && Object.keys(results[0].metric).length === 0) {
// Single scalar value
return (
<span className="ols-plugin__promql-value-badge" key={updateKey}>
{formatValue(results[0].value)}
</span>
);
}

// Multiple results with labels
return (
<div className="ols-plugin__promql-value-list" key={updateKey}>
{results.map((result, index) => {
const labelStr = Object.entries(result.metric)
.map(([k, v]) => `${k}="${v}"`)
.join(', ');
return (
<div className="ols-plugin__promql-value-item" key={index}>
<span className="ols-plugin__promql-value-badge">{formatValue(result.value)}</span>
{labelStr && <span className="ols-plugin__promql-value-labels">{labelStr}</span>}
</div>
);
})}
</div>
);
};

return (
<div className="ols-plugin__promql-value">
{renderResults()}
<TextArea
aria-label="PromQL query"
autoResize
className="ols-plugin__promql-value-input"
onChange={onChange}
resizeOrientation="vertical"
rows={2}
value={inputValue || initialQuery}
/>
</div>
);
};

export default PromQLValue;
3 changes: 2 additions & 1 deletion src/components/Prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { FileCodeIcon, FileUploadIcon, InfoCircleIcon, TaskIcon } from '@pattern

import { AttachmentTypes, toOLSAttachment } from '../attachments';
import { getApiUrl } from '../config';
import { LLM_INSTRUCTIONS } from '../llm-instructions';
import { getFetchErrorMessage } from '../error';
import { getRequestInitWithAuthHeader } from '../hooks/useAuth';
import { useBoolean } from '../hooks/useBoolean';
Expand Down Expand Up @@ -498,7 +499,7 @@ const Prompt: React.FC<PromptProps> = ({ scrollIntoView }) => {
conversation_id: conversationID,
// eslint-disable-next-line camelcase
media_type: 'application/json',
query,
query: `${query}\n\n${LLM_INSTRUCTIONS}`,
};

const streamResponse = async () => {
Expand Down
49 changes: 49 additions & 0 deletions src/components/QueryBrowserGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as React from 'react';
import { debounce } from 'lodash';
import { QueryBrowser } from '@openshift-console/dynamic-plugin-sdk';
import { Spinner, TextArea } from '@patternfly/react-core';

const QueryBrowserGraph: React.FC<{ query: string }> = ({ query: initialQuery }) => {
const [inputValue, setInputValue] = React.useState('');
const [query, setQuery] = React.useState('');

const debouncedSetQuery = React.useMemo(
() =>
debounce((value: string) => {
setQuery(value);
}, 500),
[],
);

const effectiveQuery = inputValue || initialQuery;

React.useEffect(() => {
debouncedSetQuery(effectiveQuery);
}, [debouncedSetQuery, effectiveQuery]);

const onChange = React.useCallback(
(_event: React.ChangeEvent<HTMLTextAreaElement>, value: string) => {
setInputValue(value);
},
[],
);

return (
<>
<React.Suspense fallback={<Spinner size="md" />}>
{query ? <QueryBrowser queries={[query]} showStackedControl /> : <Spinner size="md" />}
</React.Suspense>
<TextArea
aria-label="PromQL query"
autoResize
className="ols-plugin__query-browser-input"
onChange={onChange}
resizeOrientation="vertical"
rows={3}
value={inputValue || initialQuery}
/>
</>
);
};

export default QueryBrowserGraph;
63 changes: 63 additions & 0 deletions src/components/general-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,66 @@
max-width: 30rem;
text-align: center;
}

.ols-plugin__query-browser-input {
font-size: var(--pf-t--global--font--size--body--sm);
margin-bottom: var(--pf-t--global--spacer--lg);
margin-top: -25px;
}

.ols-plugin__promql-value {
margin: var(--pf-t--global--spacer--md) 0;
}

.ols-plugin__promql-value-badge {
animation: ols-plugin-flash 0.6s ease-out;
background-color: var(--pf-t--global--background--color--100);
border: 1px solid var(--pf-t--global--border--color--default);
border-radius: var(--pf-t--global--border--radius--small);
font-family: var(--pf-t--global--font--family--mono);
font-size: var(--pf-t--global--font--size--heading--md);
font-weight: var(--pf-t--global--font--weight--body--bold);
padding: var(--pf-t--global--spacer--xs) var(--pf-t--global--spacer--sm);
}

@keyframes ols-plugin-flash {
0% {
background-color: var(--pf-t--global--background--color--tertiary--default);
}

100% {
background-color: var(--pf-t--global--background--color--secondary--default);
}
}

.ols-plugin__promql-value-list {
display: flex;
flex-direction: column;
gap: var(--pf-t--global--spacer--sm);
}

.ols-plugin__promql-value-item {
align-items: baseline;
display: flex;
gap: var(--pf-t--global--spacer--md);
}

.ols-plugin__promql-value-labels {
color: var(--pf-t--global--text--color--subtle);
font-family: var(--pf-t--global--font--family--mono);
font-size: var(--pf-t--global--font--size--body--sm);
}

.ols-plugin__promql-value-error {
color: var(--pf-t--global--color--status--danger--default);
}

.ols-plugin__promql-value-empty {
color: var(--pf-t--global--text--color--subtle);
font-style: italic;
}

.ols-plugin__promql-value-input {
font-size: var(--pf-t--global--font--size--body--sm);
margin-top: var(--pf-t--global--spacer--md);
}
Loading