mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-22 20:00:29 +01:00
Compare commits
29 Commits
base-path-
...
feat/ai-as
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dadb2b39b | ||
|
|
a595feb980 | ||
|
|
40f6994042 | ||
|
|
542d984d91 | ||
|
|
c6a885bc31 | ||
|
|
dfa8625e3d | ||
|
|
a60f8551dd | ||
|
|
e607908b29 | ||
|
|
210ac2e74b | ||
|
|
5971a9efbf | ||
|
|
d43f3de049 | ||
|
|
7917540662 | ||
|
|
addb234c8c | ||
|
|
be6a663e4b | ||
|
|
83a9d8fbfe | ||
|
|
17a015d244 | ||
|
|
ed17003329 | ||
|
|
7f9f383a95 | ||
|
|
17a7227831 | ||
|
|
9c8846ae63 | ||
|
|
18bb87f778 | ||
|
|
71a5e4500c | ||
|
|
7305470e62 | ||
|
|
1e140285ae | ||
|
|
4f039da2a6 | ||
|
|
d4afc49882 | ||
|
|
2bce8c9ea0 | ||
|
|
cae757041a | ||
|
|
adabd1d8db |
@@ -45,7 +45,8 @@ const config: Config.InitialOptions = {
|
||||
'^.+\\.(js|jsx)$': 'babel-jest',
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@signozhq/design-tokens|@signozhq/table|@signozhq/calendar|@signozhq/input|@signozhq/popover|@signozhq/button|@signozhq/*|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs)/)',
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
'node_modules/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq/table|@signozhq/calendar|@signozhq/input|@signozhq/popover|@signozhq/*|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs)/)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -24,6 +24,10 @@ window.matchMedia =
|
||||
};
|
||||
};
|
||||
|
||||
if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = function (): void {};
|
||||
}
|
||||
|
||||
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
|
||||
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
|
||||
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),
|
||||
|
||||
39763
frontend/package-lock.json
generated
Normal file
39763
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -48,24 +48,10 @@
|
||||
"@radix-ui/react-tooltip": "1.0.7",
|
||||
"@sentry/react": "8.41.0",
|
||||
"@sentry/vite-plugin": "2.22.6",
|
||||
"@signozhq/button": "0.0.5",
|
||||
"@signozhq/calendar": "0.1.1",
|
||||
"@signozhq/callout": "0.0.4",
|
||||
"@signozhq/checkbox": "0.0.4",
|
||||
"@signozhq/combobox": "0.0.4",
|
||||
"@signozhq/command": "0.0.2",
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/dialog": "0.0.4",
|
||||
"@signozhq/drawer": "0.0.6",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/input": "0.0.4",
|
||||
"@signozhq/popover": "0.1.2",
|
||||
"@signozhq/radio-group": "0.0.4",
|
||||
"@signozhq/resizable": "0.0.2",
|
||||
"@signozhq/tabs": "0.0.11",
|
||||
"@signozhq/table": "0.3.8",
|
||||
"@signozhq/toggle-group": "0.0.3",
|
||||
"@signozhq/ui": "0.0.5",
|
||||
"@signozhq/ui": "0.0.9",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -126,6 +112,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-addons-update": "15.6.3",
|
||||
"react-beautiful-dnd": "13.1.1",
|
||||
"react-chartjs-2": "4",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
@@ -149,6 +136,7 @@
|
||||
"redux": "^4.0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"rehype-raw": "7.0.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"rollup-plugin-visualizer": "7.0.0",
|
||||
"rrule": "2.8.1",
|
||||
"stream": "^0.0.2",
|
||||
|
||||
9
frontend/req.md
Normal file
9
frontend/req.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# SigNoz AI Assistant
|
||||
|
||||
1. Chat interface (Side Drawer View)
|
||||
1. Should be able to expand the view to full screen (open in a new route - with converstation ID)
|
||||
2. Conversation would be stream (for in process message), the older messages would be listed (Virtualized) - older - newest
|
||||
2. Input Section
|
||||
1. Users should be able to upload images / files to the chat
|
||||
|
||||
|
||||
@@ -99,6 +99,13 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const isAIAssistantEnabled =
|
||||
featureFlags?.find((f) => f.name === FeatureKeys.AI_ASSISTANT_ENABLED)
|
||||
?.active ?? false;
|
||||
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
|
||||
return <Redirect to={ROUTES.HOME} />;
|
||||
}
|
||||
|
||||
// Check for workspace access restriction (cloud only)
|
||||
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;
|
||||
|
||||
|
||||
@@ -212,6 +212,30 @@ function App(): JSX.Element {
|
||||
activeLicenseFetchError,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedInState || isFetchingFeatureFlags) {
|
||||
return;
|
||||
}
|
||||
const isAIAssistantEnabled =
|
||||
featureFlags?.find((f) => f.name === FeatureKeys.AI_ASSISTANT_ENABLED)
|
||||
?.active ?? false;
|
||||
|
||||
setRoutes((prev) => {
|
||||
const hasAi = prev.some((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
if (isAIAssistantEnabled === hasAi) {
|
||||
return prev;
|
||||
}
|
||||
if (isAIAssistantEnabled) {
|
||||
const aiRoute = defaultRoutes.find((r) => r.path === ROUTES.AI_ASSISTANT);
|
||||
if (!aiRoute) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT), aiRoute];
|
||||
}
|
||||
return prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT);
|
||||
});
|
||||
}, [isLoggedInState, isFetchingFeatureFlags, featureFlags]);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -221,7 +245,8 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/')
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname.startsWith('/ai-assistant/')
|
||||
) {
|
||||
window.Pylon?.('hideChatBubble');
|
||||
} else {
|
||||
|
||||
@@ -317,3 +317,10 @@ export const MeterExplorerPage = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
|
||||
);
|
||||
|
||||
export const AIAssistantPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { RouteProps } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
AIAssistantPage,
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AlertTypeSelectionPage,
|
||||
@@ -496,6 +497,13 @@ const routes: AppRoutes[] = [
|
||||
key: 'API_MONITORING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
component: AIAssistantPage,
|
||||
key: 'AI_ASSISTANT',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SUPPORT_ROUTE: AppRoutes = {
|
||||
|
||||
466
frontend/src/api/ai/chat.ts
Normal file
466
frontend/src/api/ai/chat.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* AI Assistant API client.
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST /api/v1/assistant/threads → { threadId }
|
||||
* 2. POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
|
||||
* 3. GET /api/v1/assistant/executions/{executionId}/events → SSE stream (closes on 'done')
|
||||
*
|
||||
* For subsequent messages in the same thread, repeat steps 2–3.
|
||||
* Approval/clarification events pause the stream; use approveExecution/clarifyExecution
|
||||
* to resume, which each return a new executionId to open a fresh SSE stream.
|
||||
*/
|
||||
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
// Direct URL to the AI backend — set VITE_AI_BACKEND_URL in .env (see vite.config `define`).
|
||||
const AI_BACKEND = process.env.VITE_AI_BACKEND_URL || 'http://localhost:8001';
|
||||
const BASE = `${AI_BACKEND}/api/v1/assistant`;
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE event types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SSEEvent =
|
||||
| { type: 'status'; executionId: string; state: string; eventId: number }
|
||||
| {
|
||||
type: 'message';
|
||||
executionId: string;
|
||||
messageId: string;
|
||||
delta: string;
|
||||
done: boolean;
|
||||
actions: unknown[] | null;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'thinking';
|
||||
executionId: string;
|
||||
content: string;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'tool_call';
|
||||
executionId: string;
|
||||
toolName: string;
|
||||
toolInput: unknown;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'tool_result';
|
||||
executionId: string;
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'approval';
|
||||
executionId: string;
|
||||
approvalId: string;
|
||||
actionType: string;
|
||||
resourceType: string;
|
||||
summary: string;
|
||||
diff: { before: unknown; after: unknown } | null;
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'clarification';
|
||||
executionId: string;
|
||||
clarificationId: string;
|
||||
message: string;
|
||||
discoveredContext: Record<string, unknown> | null;
|
||||
fields: ClarificationFieldRaw[];
|
||||
eventId: number;
|
||||
}
|
||||
| {
|
||||
type: 'error';
|
||||
executionId: string;
|
||||
error: { type: string; code: string; message: string; details: unknown };
|
||||
retryAction: 'auto' | 'manual' | 'none';
|
||||
eventId: number;
|
||||
}
|
||||
| { type: 'conversation'; threadId: string; title: string; eventId: number }
|
||||
| {
|
||||
type: 'done';
|
||||
executionId: string;
|
||||
tokenInput: number;
|
||||
tokenOutput: number;
|
||||
latencyMs: number;
|
||||
toolCallCount?: number;
|
||||
retryCount?: number;
|
||||
eventId: number;
|
||||
};
|
||||
|
||||
export interface ClarificationFieldRaw {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[] | null;
|
||||
default?: string | string[] | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 1 — Create thread
|
||||
// POST /api/v1/assistant/threads → { threadId }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread listing & detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ThreadSummary {
|
||||
threadId: string;
|
||||
title: string | null;
|
||||
state: string | null;
|
||||
activeExecutionId: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadListResponse {
|
||||
threads: ThreadSummary[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface MessageSummaryBlock {
|
||||
type: string;
|
||||
content?: string;
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
result?: unknown;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageSummary {
|
||||
messageId: string;
|
||||
role: string;
|
||||
contentType: string;
|
||||
content: string | null;
|
||||
complete: boolean;
|
||||
toolCalls: Record<string, unknown>[] | null;
|
||||
blocks: MessageSummaryBlock[] | null;
|
||||
actions: unknown[] | null;
|
||||
feedbackRating: 'positive' | 'negative' | null;
|
||||
feedbackComment: string | null;
|
||||
executionId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadDetailResponse {
|
||||
threadId: string;
|
||||
title: string | null;
|
||||
state: string | null;
|
||||
activeExecutionId: string | null;
|
||||
archived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: MessageSummary[];
|
||||
pendingApproval: unknown | null;
|
||||
pendingClarification: unknown | null;
|
||||
}
|
||||
|
||||
export async function listThreads(
|
||||
cursor?: string | null,
|
||||
limit = 20,
|
||||
): Promise<ThreadListResponse> {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) {
|
||||
params.set('cursor', cursor);
|
||||
}
|
||||
const res = await fetch(`${BASE}/threads?${params.toString()}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to list threads: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateThread(
|
||||
threadId: string,
|
||||
update: { title?: string | null; archived?: boolean | null },
|
||||
): Promise<ThreadSummary> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(update),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to update thread: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getThreadDetail(
|
||||
threadId: string,
|
||||
): Promise<ThreadDetailResponse> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to get thread: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createThread(signal?: AbortSignal): Promise<string> {
|
||||
const res = await fetch(`${BASE}/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({}),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to create thread: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { threadId: string } = await res.json();
|
||||
return data.threadId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 2 — Send message
|
||||
// POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fetches the thread's active executionId for reconnect on thread_busy (409). */
|
||||
async function getActiveExecutionId(threadId: string): Promise<string | null> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}`, {
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
const data: { activeExecutionId?: string | null } = await res.json();
|
||||
return data.activeExecutionId ?? null;
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
threadId: string,
|
||||
content: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${BASE}/threads/${threadId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ content }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (res.status === 409) {
|
||||
// Thread has an active execution — reconnect to it instead of failing.
|
||||
const executionId = await getActiveExecutionId(threadId);
|
||||
if (executionId) {
|
||||
return executionId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to send message: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { executionId: string } = await res.json();
|
||||
return data.executionId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 3 — Stream execution events
|
||||
// GET /api/v1/assistant/executions/{executionId}/events → SSE
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseSSELine(line: string): SSEEvent | null {
|
||||
if (!line.startsWith('data: ')) {
|
||||
return null;
|
||||
}
|
||||
const json = line.slice('data: '.length).trim();
|
||||
if (!json || json === '[DONE]') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(json) as SSEEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSSEChunk(chunk: string): SSEEvent[] {
|
||||
return chunk
|
||||
.split('\n\n')
|
||||
.map((part) => part.split('\n').find((l) => l.startsWith('data: ')) ?? '')
|
||||
.map(parseSSELine)
|
||||
.filter((e): e is SSEEvent => e !== null);
|
||||
}
|
||||
|
||||
async function* readSSEReader(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const decoder = new TextDecoder();
|
||||
let lineBuffer = '';
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
lineBuffer += decoder.decode(value, { stream: true });
|
||||
const parts = lineBuffer.split('\n\n');
|
||||
lineBuffer = parts.pop() ?? '';
|
||||
yield* parts.flatMap(parseSSEChunk);
|
||||
}
|
||||
yield* parseSSEChunk(lineBuffer);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamEvents(
|
||||
executionId: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<SSEEvent> {
|
||||
const res = await fetch(`${BASE}/executions/${executionId}/events`, {
|
||||
headers: { ...authHeaders() },
|
||||
signal,
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`SSE stream failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
yield* readSSEReader(res.body.getReader());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Approval / Clarification / Cancel actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Approve a pending action. Returns a new executionId — open a fresh SSE stream for it. */
|
||||
export async function approveExecution(
|
||||
approvalId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${BASE}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ approvalId }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to approve: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { executionId: string } = await res.json();
|
||||
return data.executionId;
|
||||
}
|
||||
|
||||
/** Reject a pending action. */
|
||||
export async function rejectExecution(
|
||||
approvalId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ approvalId }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to reject: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Submit clarification answers. Returns a new executionId — open a fresh SSE stream for it. */
|
||||
export async function clarifyExecution(
|
||||
clarificationId: string,
|
||||
answers: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${BASE}/clarify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ clarificationId, answers }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to clarify: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
const data: { executionId: string } = await res.json();
|
||||
return data.executionId;
|
||||
}
|
||||
|
||||
/** Cancel the active execution on a thread. */
|
||||
export async function cancelExecution(
|
||||
threadId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ threadId }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to cancel: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FeedbackRating = 'positive' | 'negative';
|
||||
|
||||
export async function submitFeedback(
|
||||
messageId: string,
|
||||
rating: FeedbackRating,
|
||||
comment?: string,
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${BASE}/messages/${messageId}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ rating, comment: comment ?? null }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
`Failed to submit feedback: ${res.status} ${res.statusText} — ${body}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
14
frontend/src/auto-import-registry.d.ts
vendored
14
frontend/src/auto-import-registry.d.ts
vendored
@@ -10,21 +10,7 @@
|
||||
// PR for reference: https://github.com/SigNoz/signoz/pull/9694
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
import '@signozhq/button';
|
||||
import '@signozhq/calendar';
|
||||
import '@signozhq/callout';
|
||||
import '@signozhq/checkbox';
|
||||
import '@signozhq/combobox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/dialog';
|
||||
import '@signozhq/drawer';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
import '@signozhq/radio-group';
|
||||
import '@signozhq/resizable';
|
||||
import '@signozhq/tabs';
|
||||
import '@signozhq/table';
|
||||
import '@signozhq/toggle-group';
|
||||
import '@signozhq/ui';
|
||||
|
||||
@@ -80,12 +80,12 @@
|
||||
|
||||
mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
color-mix(in srgb, var(--background) 10%, transparent) 0,
|
||||
color-mix(in srgb, var(--l1-background) 10%, transparent) 0,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
color-mix(in srgb, var(--background) 10%, transparent) 0,
|
||||
color-mix(in srgb, var(--l1-background) 10%, transparent) 0,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
animation: horizontal-shaking 300ms ease-out;
|
||||
|
||||
.error-content {
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--bg-cherry-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
border-radius: 4px;
|
||||
|
||||
&__summary-section {
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, var(--bg-cherry-500) 20%, transparent);
|
||||
color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
}
|
||||
|
||||
&__summary {
|
||||
@@ -58,7 +59,7 @@
|
||||
&__message-badge-line {
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
color-mix(in srgb, var(--bg-cherry-500) 30%, transparent) 1px,
|
||||
color-mix(in srgb, var(--danger-background) 30%, transparent) 1px,
|
||||
transparent 2px
|
||||
);
|
||||
}
|
||||
@@ -84,7 +85,7 @@
|
||||
}
|
||||
|
||||
&__scroll-hint {
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
}
|
||||
|
||||
&__scroll-hint-text {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { LifeBuoy } from 'lucide-react';
|
||||
|
||||
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
|
||||
@@ -23,8 +23,10 @@ function AuthHeader(): JSX.Element {
|
||||
</div>
|
||||
<Button
|
||||
className="auth-header-help-button"
|
||||
prefixIcon={<LifeBuoy size={12} />}
|
||||
prefix={<LifeBuoy size={12} />}
|
||||
onClick={handleGetHelp}
|
||||
variant="solid"
|
||||
color="none"
|
||||
>
|
||||
Get Help
|
||||
</Button>
|
||||
|
||||
@@ -43,12 +43,12 @@
|
||||
.masked-dots {
|
||||
mask-image: radial-gradient(
|
||||
circle at 50% 0%,
|
||||
color-mix(in srgb, var(--background) 10%, transparent) 0%,
|
||||
color-mix(in srgb, var(--l1-background) 10%, transparent) 0%,
|
||||
transparent 56.77%
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at 50% 0%,
|
||||
color-mix(in srgb, var(--background) 10%, transparent) 0%,
|
||||
color-mix(in srgb, var(--l1-background) 10%, transparent) 0%,
|
||||
transparent 56.77%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
}
|
||||
}
|
||||
&--negative {
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
|
||||
.change-percentage-pill {
|
||||
&__icon {
|
||||
color: var(--bg-cherry-500);
|
||||
color: var(--danger-background);
|
||||
}
|
||||
&__label {
|
||||
color: var(--bg-cherry-500);
|
||||
color: var(--danger-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import {
|
||||
Button,
|
||||
DialogFooter,
|
||||
DialogWrapper,
|
||||
Input,
|
||||
toast,
|
||||
} from '@signozhq/ui';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
invalidateListServiceAccounts,
|
||||
@@ -50,9 +53,7 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
} = useCreateServiceAccount({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
toast.success('Service account created successfully', {
|
||||
richColors: true,
|
||||
});
|
||||
toast.success('Service account created successfully');
|
||||
reset();
|
||||
await setIsOpen(null);
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
@@ -128,7 +129,6 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={12} />
|
||||
@@ -137,10 +137,10 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
@@ -69,7 +75,6 @@ describe('CreateServiceAccountModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Service account created successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -121,12 +126,12 @@ describe('CreateServiceAccountModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await screen.findByRole('dialog', { name: /New Service Account/i });
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: /New Service Account/i,
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /New Service Account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
await waitForElementToBeRemoved(dialog);
|
||||
});
|
||||
|
||||
it('shows "Name is required" after clearing the name field', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Calendar } from '@signozhq/calendar';
|
||||
import { Calendar } from '@signozhq/ui';
|
||||
import { Button } from 'antd';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Input, InputRef, Popover, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -661,7 +661,9 @@ function CustomTimePicker({
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomOutDisabled}
|
||||
data-testid="zoom-out-btn"
|
||||
prefixIcon={<ZoomOut size={14} />}
|
||||
prefix={<ZoomOut size={14} />}
|
||||
variant="solid"
|
||||
color="none"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
.download-popover {
|
||||
.ant-popover-inner {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
var(--l2-background) 0%,
|
||||
var(--l3-background) 98.68%
|
||||
);
|
||||
background-color: var(--l2-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 0 8px 12px 8px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
@@ -19,7 +13,7 @@
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
color: var(--l3-foreground);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
@@ -38,7 +32,7 @@
|
||||
flex-direction: column;
|
||||
|
||||
:global(.ant-radio-wrapper) {
|
||||
color: var(--foreground);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button, DialogWrapper } from '@signozhq/ui';
|
||||
import { MemberRow } from 'components/MembersTable/MembersTable';
|
||||
|
||||
interface DeleteMemberDialogProps {
|
||||
@@ -36,6 +35,24 @@ function DeleteMemberDialog({
|
||||
</>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
disabled={isDeleting}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDeleting ? 'Processing...' : title}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
@@ -49,25 +66,9 @@ function DeleteMemberDialog({
|
||||
className="alert-dialog delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={false}
|
||||
footer={footer}
|
||||
>
|
||||
<p className="delete-dialog__body">{body}</p>
|
||||
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDeleting ? 'Processing...' : title}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{body}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
&__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__body {
|
||||
@@ -11,7 +11,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--padding-5) var(--padding-4);
|
||||
}
|
||||
|
||||
&__field {
|
||||
@@ -50,6 +49,7 @@
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
box-sizing: border-box;
|
||||
|
||||
&--disabled {
|
||||
cursor: not-allowed;
|
||||
@@ -120,17 +120,11 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 0 var(--padding-4);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
&__footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
@@ -223,10 +217,6 @@
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
[data-slot='dialog-description'] {
|
||||
width: 510px;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import { LockKeyhole, RefreshCw, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Badge, toast } from '@signozhq/ui';
|
||||
import { Badge, Button, DrawerWrapper, Input, toast } from '@signozhq/ui';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -207,7 +204,7 @@ function EditMemberDrawer({
|
||||
onSuccess: (): void => {
|
||||
toast.success(
|
||||
isInvited ? 'Invite revoked successfully' : 'Member deleted successfully',
|
||||
{ richColors: true, position: 'top-right' },
|
||||
{ position: 'top-right' },
|
||||
);
|
||||
setShowDeleteConfirm(false);
|
||||
onComplete();
|
||||
@@ -342,10 +339,7 @@ function EditMemberDrawer({
|
||||
if (errors.length > 0) {
|
||||
setSaveErrors(errors);
|
||||
} else {
|
||||
toast.success('Member details updated successfully', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
toast.success('Member details updated successfully');
|
||||
onComplete();
|
||||
}
|
||||
|
||||
@@ -403,7 +397,6 @@ function EditMemberDrawer({
|
||||
onClose();
|
||||
} else {
|
||||
toast.error('Failed to generate password reset link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
}
|
||||
@@ -427,15 +420,12 @@ function EditMemberDrawer({
|
||||
linkType === 'invite'
|
||||
? 'Invite link copied to clipboard'
|
||||
: 'Reset link copied to clipboard';
|
||||
toast.success(message, { richColors: true, position: 'top-right' });
|
||||
toast.success(message);
|
||||
}, [resetLink, copyToClipboard, linkType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState.error) {
|
||||
toast.error('Failed to copy link', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
toast.error('Failed to copy link');
|
||||
}
|
||||
}, [copyState.error]);
|
||||
|
||||
@@ -596,16 +586,21 @@ function EditMemberDrawer({
|
||||
const drawerContent = (
|
||||
<div className="edit-member-drawer__layout">
|
||||
<div className="edit-member-drawer__body">{drawerBody}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<div className="edit-member-drawer__footer">
|
||||
{!isDeleted && (
|
||||
<div className="edit-member-drawer__footer">
|
||||
<>
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--danger"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
@@ -617,9 +612,10 @@ function EditMemberDrawer({
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
className="edit-member-drawer__footer-btn edit-member-drawer__footer-btn--warning"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
|
||||
variant="link"
|
||||
color="warning"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink
|
||||
@@ -638,7 +634,7 @@ function EditMemberDrawer({
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleClose}>
|
||||
<Button variant="solid" color="secondary" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -646,14 +642,13 @@ function EditMemberDrawer({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Member Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -668,14 +663,14 @@ function EditMemberDrawer({
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
header={{ title: 'Member Details' }}
|
||||
content={drawerContent}
|
||||
className="edit-member-drawer"
|
||||
/>
|
||||
title="Member Details"
|
||||
footer={footer}
|
||||
width="wide"
|
||||
>
|
||||
{drawerContent}
|
||||
</DrawerWrapper>
|
||||
|
||||
<ResetLinkDialog
|
||||
open={showResetLinkDialog}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button, DialogWrapper } from '@signozhq/ui';
|
||||
|
||||
interface ResetLinkDialogProps {
|
||||
open: boolean;
|
||||
@@ -49,7 +48,7 @@ function ResetLinkDialog({
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={onCopy}
|
||||
prefixIcon={hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
prefix={hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
className="reset-link-dialog__copy-btn"
|
||||
>
|
||||
{hasCopied ? 'Copied!' : 'Copy'}
|
||||
|
||||
@@ -20,36 +20,6 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
|
||||
|
||||
jest.mock('@signozhq/drawer', () => ({
|
||||
DrawerWrapper: ({
|
||||
content,
|
||||
open,
|
||||
}: {
|
||||
content?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null => (open ? <div>{content}</div> : null),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/dialog', () => ({
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/users', () => ({
|
||||
useDeleteUser: jest.fn(),
|
||||
useGetUser: jest.fn(),
|
||||
@@ -66,6 +36,41 @@ jest.mock('api/ErrorResponseHandlerForGeneratedAPIs', () => ({
|
||||
|
||||
jest.mock('@signozhq/ui', () => ({
|
||||
...jest.requireActual('@signozhq/ui'),
|
||||
DrawerWrapper: ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
DialogWrapper: ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
@@ -160,6 +165,8 @@ function renderDrawer(
|
||||
describe('EditMemberDrawer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockCopyState.value = undefined;
|
||||
mockCopyState.error = undefined;
|
||||
showErrorModal.mockClear();
|
||||
server.use(
|
||||
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
|
||||
@@ -726,16 +733,16 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(screen.getByRole('button', { name: /^copy$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
expect.stringContaining('reset-tok-abc'),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /copied!/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Reset link copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
expect.stringContaining('reset-tok-abc'),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 20px;
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
padding-left: 3px;
|
||||
padding-right: 8px;
|
||||
cursor: pointer;
|
||||
span {
|
||||
color: var(--bg-cherry-500);
|
||||
color: var(--danger-background);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 200% */
|
||||
@@ -21,7 +21,7 @@
|
||||
&__wrap {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--background) 12%, transparent) 0.07%,
|
||||
color-mix(in srgb, var(--l1-background) 12%, transparent) 0.07%,
|
||||
color-mix(in srgb, var(--bg-sakura-950) 24%, transparent) 50.04%,
|
||||
color-mix(in srgb, var(--bg-sakura-800) 36%, transparent) 75.02%,
|
||||
color-mix(in srgb, var(--bg-sakura-600) 48%, transparent) 87.51%,
|
||||
@@ -40,15 +40,17 @@
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 0;
|
||||
background: var(--l2-background);
|
||||
overflow: hidden;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
background: none !important;
|
||||
|
||||
.ant-modal-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -80,6 +82,7 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 3px 7px;
|
||||
background: var(--l2-background);
|
||||
@@ -90,15 +93,15 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
margin: 0 !important;
|
||||
height: 6px;
|
||||
background: var(--bg-sakura-500);
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 0 !important;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
&__summary-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&__summary {
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Popover } from 'antd';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AIAssistantIcon from 'container/AIAssistant/components/AIAssistantIcon';
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { Globe, Inbox, SquarePen } from 'lucide-react';
|
||||
|
||||
import AnnouncementsModal from './AnnouncementsModal';
|
||||
@@ -29,6 +36,7 @@ function HeaderRightSection({
|
||||
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
|
||||
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const handleOpenFeedbackModal = useCallback((): void => {
|
||||
logEvent('Feedback: Clicked', {
|
||||
@@ -67,9 +75,22 @@ function HeaderRightSection({
|
||||
};
|
||||
|
||||
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
|
||||
return (
|
||||
<div className="header-right-section-container">
|
||||
{isAIAssistantEnabled && !isDrawerOpen && (
|
||||
<Tooltip title="AI Assistant">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={openAIAssistant}
|
||||
aria-label="Open AI Assistant"
|
||||
prefix={<AIAssistantIcon />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{enableFeedback && isLicenseEnabled && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
@@ -83,12 +104,11 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenFeedbackModalChange}
|
||||
>
|
||||
<Button
|
||||
className="share-feedback-btn periscope-btn ghost"
|
||||
icon={<SquarePen size={14} />}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
prefix={<SquarePen size={14} />}
|
||||
onClick={handleOpenFeedbackModal}
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
@@ -105,8 +125,9 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenAnnouncementsModalChange}
|
||||
>
|
||||
<Button
|
||||
icon={<Inbox size={14} />}
|
||||
className="periscope-btn ghost announcements-btn"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
prefix={<Inbox size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Announcements: Clicked', {
|
||||
page: location.pathname,
|
||||
@@ -129,12 +150,11 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenShareURLModalChange}
|
||||
>
|
||||
<Button
|
||||
className="share-link-btn periscope-btn ghost"
|
||||
icon={<Globe size={14} />}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
prefix={<Globe size={14} />}
|
||||
onClick={handleOpenShareURLModal}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,10 @@ jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useIsAIAssistantEnabled', () => ({
|
||||
useIsAIAssistantEnabled: (): boolean => false,
|
||||
}));
|
||||
|
||||
const mockLogEvent = logEvent as jest.Mock;
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
|
||||
.label {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
@@ -21,8 +20,9 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
@@ -46,6 +47,7 @@
|
||||
border-bottom-left-radius: 0px;
|
||||
font-size: 12px !important;
|
||||
line-height: 27px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l2-foreground) !important;
|
||||
font-size: 12px !important;
|
||||
@@ -61,8 +63,8 @@
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
}
|
||||
@@ -71,7 +73,7 @@
|
||||
.input {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
background: var(--l2-background);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 10%, transparent);
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
@@ -196,12 +196,16 @@
|
||||
}
|
||||
|
||||
.invite-team-members-error-callout {
|
||||
background: color-mix(in srgb, var(--bg-cherry-500) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--bg-cherry-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--danger-background) 20%, transparent);
|
||||
border-radius: 4px;
|
||||
animation: horizontal-shaking 300ms ease-out;
|
||||
}
|
||||
|
||||
.invite-members-modal__error-callout {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes horizontal-shaking {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { ChevronDown, CircleAlert, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import {
|
||||
Button,
|
||||
Callout,
|
||||
DialogFooter,
|
||||
DialogWrapper,
|
||||
Input,
|
||||
toast,
|
||||
} from '@signozhq/ui';
|
||||
import { Select } from 'antd';
|
||||
import inviteUsers from 'api/v1/invite/bulk/create';
|
||||
import sendInvite from 'api/v1/invite/create';
|
||||
@@ -200,10 +203,7 @@ function InviteMembersModal({
|
||||
})),
|
||||
});
|
||||
}
|
||||
toast.success('Invites sent successfully', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
toast.success('Invites sent successfully', { position: 'top-right' });
|
||||
resetAndClose();
|
||||
onComplete?.();
|
||||
} catch (err) {
|
||||
@@ -274,7 +274,6 @@ function InviteMembersModal({
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="remove-team-member-button"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
>
|
||||
@@ -289,14 +288,16 @@ function InviteMembersModal({
|
||||
</div>
|
||||
|
||||
{(hasInvalidEmails || hasInvalidRoles) && (
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
className="invite-team-members-error-callout"
|
||||
description={getValidationErrorMessage()}
|
||||
/>
|
||||
<div className="invite-members-modal__error-callout">
|
||||
<Callout
|
||||
type="error"
|
||||
size="small"
|
||||
showIcon
|
||||
icon={<CircleAlert size={12} />}
|
||||
>
|
||||
{getValidationErrorMessage()}
|
||||
</Callout>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -304,9 +305,8 @@ function InviteMembersModal({
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="add-another-member-button"
|
||||
prefixIcon={<Plus size={12} color={Style.L1_FOREGROUND} />}
|
||||
prefix={<Plus size={12} color={Style.L1_FOREGROUND} />}
|
||||
onClick={addRow}
|
||||
>
|
||||
Add another
|
||||
@@ -317,7 +317,6 @@ function InviteMembersModal({
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={resetAndClose}
|
||||
>
|
||||
<X size={12} />
|
||||
@@ -327,7 +326,6 @@ function InviteMembersModal({
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitDisabled}
|
||||
loading={isSubmitting}
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
}
|
||||
|
||||
&.ERROR {
|
||||
border-color: var(--bg-cherry-500);
|
||||
border-color: var(--danger-background);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux'; // old code, TODO: fix this correctly
|
||||
import { useCopyToClipboard, useLocation } from 'react-use';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Drawer, Radio, Tooltip, Typography } from 'antd';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Divider, Drawer, Radio, Tooltip, Typography } from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd/lib';
|
||||
import cx from 'classnames';
|
||||
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
@@ -363,7 +364,9 @@ function LogDetailInner({
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
icon={<ChevronUp size={14} />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronUp size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-up"
|
||||
disabled={isPrevDisabled}
|
||||
onClick={(): void => handleNavigateLog({ direction: 'previous' })}
|
||||
@@ -375,7 +378,9 @@ function LogDetailInner({
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
icon={<ChevronDown size={14} />}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronDown size={14} />}
|
||||
className="log-arrow-btn log-arrow-btn-down"
|
||||
disabled={isNextDisabled}
|
||||
onClick={(): void => handleNavigateLog({ direction: 'next' })}
|
||||
@@ -385,8 +390,10 @@ function LogDetailInner({
|
||||
{showOpenInExplorerBtn && (
|
||||
<div>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<Compass size={16} />}
|
||||
className="open-in-explorer-btn"
|
||||
icon={<Compass size={16} />}
|
||||
onClick={handleOpenInExplorer}
|
||||
>
|
||||
Open in Explorer
|
||||
@@ -482,8 +489,10 @@ function LogDetailInner({
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Filter size={16} />}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Filter size={12} />}
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -498,8 +507,10 @@ function LogDetailInner({
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Copy size={16} />}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Copy size={12} />}
|
||||
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
background-color: var(--bg-cherry-600);
|
||||
}
|
||||
&.severity-error-1 {
|
||||
background-color: var(--bg-cherry-500);
|
||||
background-color: var(--danger-background);
|
||||
}
|
||||
&.severity-error-2 {
|
||||
background-color: var(--bg-cherry-400);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TableProps } from 'antd';
|
||||
|
||||
export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
|
||||
@@ -7,7 +8,7 @@ export function getDefaultCellStyle(isDarkMode?: boolean): CSSProperties {
|
||||
paddingBottom: 6,
|
||||
paddingRight: 8,
|
||||
paddingLeft: 8,
|
||||
color: isDarkMode ? 'var(--bg-vanilla-400)' : 'var(--bg-slate-400)',
|
||||
color: isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400,
|
||||
fontSize: '14px',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 400,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -10,7 +11,7 @@ interface TableBodyContentProps {
|
||||
export const TableBodyContent = styled.div<TableBodyContentProps>`
|
||||
margin-bottom: 0;
|
||||
color: ${(props): string =>
|
||||
props.isDarkMode ? 'var(--bg-vanilla-400, #c0c1c3)' : 'var(--bg-slate-400)'};
|
||||
props.isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400};
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
||||
@@ -33,8 +33,9 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
.timestamp-text {
|
||||
color: var(--l1-foreground);
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
return {
|
||||
children: (
|
||||
<div className="table-timestamp">
|
||||
<p className={cx('text', fontSize)}>{date}</p>
|
||||
<p className={cx('timestamp-text text', fontSize)}>{date}</p>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--muted-foreground);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
@@ -93,7 +93,7 @@
|
||||
gap: 12px;
|
||||
|
||||
.title {
|
||||
color: var(--muted-foreground);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
@@ -139,7 +139,8 @@
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: left;
|
||||
color: var(--muted-foreground);
|
||||
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.menu-items {
|
||||
@@ -177,7 +178,7 @@
|
||||
padding: 12px;
|
||||
|
||||
.title {
|
||||
color: var(--muted-foreground);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
@@ -330,7 +331,7 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--muted-foreground);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
@@ -486,169 +487,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.format-options-popover {
|
||||
.ant-popover-inner {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(255, 255, 255, 0.8) 0%,
|
||||
rgba(255, 255, 255, 0.9) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
|
||||
|
||||
.nested-menu-container {
|
||||
.font-size-dropdown {
|
||||
.back-btn {
|
||||
.text {
|
||||
color: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
.option-btn {
|
||||
.text {
|
||||
color: var(--l2-background);
|
||||
}
|
||||
|
||||
.text:hover {
|
||||
color: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-column-container {
|
||||
.add-new-column-header {
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-column-content {
|
||||
.column-format-new-options {
|
||||
.column-name {
|
||||
color: var(--l2-background);
|
||||
|
||||
&.selected {
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
color: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.font-size-container {
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.value {
|
||||
.font-value {
|
||||
color: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal-line {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.item-content {
|
||||
.column-divider {
|
||||
border-top: 2px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.max-lines-per-row {
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.lucide {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.max-lines-per-row-input {
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.periscope-btn {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.item {
|
||||
.item-label {
|
||||
color: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected-item-content-container {
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.lucide {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.horizontal-line {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.item-content {
|
||||
.max-lines-per-row-input {
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.periscope-btn {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.column-format,
|
||||
.column-format-new-options {
|
||||
.column-name {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.nested-menu-container {
|
||||
backdrop-filter: blur(18px);
|
||||
|
||||
.item {
|
||||
.item-label {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selected-item-content-container {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(255, 255, 255, 0.8) 0%,
|
||||
rgba(255, 255, 255, 0.9) 98.68%
|
||||
);
|
||||
|
||||
box-shadow: 4px 10px 16px 2px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
.ant-table-thead {
|
||||
> tr > th,
|
||||
> tr > td {
|
||||
background: var(--background);
|
||||
background: var(--l1-background);
|
||||
font-size: var(--paragraph-small-600-font-size);
|
||||
font-weight: var(--paragraph-small-600-font-weight);
|
||||
line-height: var(--paragraph-small-600-line-height);
|
||||
|
||||
@@ -1445,11 +1445,22 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
|
||||
// Custom dropdown render with sections support
|
||||
const customDropdownRender = useCallback((): React.ReactElement => {
|
||||
// Process options based on current search
|
||||
const processedOptions =
|
||||
selectedValues.length > 0 && isEmpty(searchText)
|
||||
? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues)
|
||||
: filteredOptions;
|
||||
// When ALL is selected and the options contain sections (groups),
|
||||
// skip prioritization so section headers (e.g. "Related values" /
|
||||
// "All values") remain visible instead of being collapsed away by
|
||||
// every option getting hoisted to the top. For flat option lists we
|
||||
// still prioritize so selected/synthesized values stay rendered.
|
||||
const hasSections = filteredOptions.some(
|
||||
(opt) => 'options' in opt && Array.isArray(opt.options),
|
||||
);
|
||||
const shouldPrioritize =
|
||||
selectedValues.length > 0 &&
|
||||
isEmpty(searchText) &&
|
||||
!(hasSections && (allOptionShown || isAllSelected));
|
||||
|
||||
const processedOptions = shouldPrioritize
|
||||
? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues)
|
||||
: filteredOptions;
|
||||
|
||||
const { sectionOptions, nonSectionOptions } = splitOptions(processedOptions);
|
||||
|
||||
@@ -1747,6 +1758,8 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}, [
|
||||
selectedValues,
|
||||
searchText,
|
||||
allOptionShown,
|
||||
isAllSelected,
|
||||
filteredOptions,
|
||||
splitOptions,
|
||||
isLabelPresent,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CustomMultiSelect from '../CustomMultiSelect';
|
||||
|
||||
@@ -283,4 +284,68 @@ describe('CustomMultiSelect Component', () => {
|
||||
// When all options are selected, component shows ALL tag instead
|
||||
expect(screen.getByText('ALL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('section visibility when ALL is selected', () => {
|
||||
it('keeps group headers visible when every grouped value is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockGroupedOptions}
|
||||
value={['g1-option1', 'g1-option2', 'g2-option1', 'g2-option2']}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Group 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps every grouped option visible within its section when all are selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockGroupedOptions}
|
||||
value={['g1-option1', 'g1-option2', 'g2-option1', 'g2-option2']}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
await waitFor(() => {
|
||||
const group1Region = screen.getByRole('group', {
|
||||
name: 'Group 1 options',
|
||||
});
|
||||
const group2Region = screen.getByRole('group', {
|
||||
name: 'Group 2 options',
|
||||
});
|
||||
|
||||
// Each option stays inside its original section rather than being
|
||||
// hoisted into a flat selected-first list.
|
||||
expect(group1Region).toHaveTextContent('Group 1 - Option 1');
|
||||
expect(group1Region).toHaveTextContent('Group 1 - Option 2');
|
||||
expect(group2Region).toHaveTextContent('Group 2 - Option 1');
|
||||
expect(group2Region).toHaveTextContent('Group 2 - Option 2');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps group headers visible when value is the ALL sentinel', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithVirtuoso(
|
||||
<CustomMultiSelect
|
||||
options={mockGroupedOptions}
|
||||
value={('__ALL__' as unknown) as string[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('combobox'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Group 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -355,7 +355,7 @@ $custom-border-color: #2c3044;
|
||||
.navigation-error {
|
||||
.navigation-text,
|
||||
.navigation-icons {
|
||||
color: var(--bg-cherry-500) !important;
|
||||
color: var(--danger-background) !important;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
|
||||
|
||||
.query-builder-v2 {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -274,7 +276,7 @@
|
||||
.ant-input-group-addon {
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
background: var(--l3-background);
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
|
||||
@@ -50,8 +50,8 @@ const havingOperators = [
|
||||
value: 'IN',
|
||||
},
|
||||
{
|
||||
label: 'NOT_IN',
|
||||
value: 'NOT_IN',
|
||||
label: 'NOT IN',
|
||||
value: 'NOT IN',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -129,7 +129,7 @@ function HavingFilter({
|
||||
const operator = havingOperators[j];
|
||||
newOptions.push({
|
||||
label: `${opt.func}(${opt.arg}) ${operator.label}`,
|
||||
value: `${opt.func}(${opt.arg}) ${operator.label} `,
|
||||
value: `${opt.func}(${opt.arg}) ${operator.value} `,
|
||||
apply: (
|
||||
view: EditorView,
|
||||
completion: { label: string; value: string },
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
|
||||
|
||||
.query-add-ons {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -102,7 +104,7 @@
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
padding: 0px !important;
|
||||
background-color: var(--card) !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
@@ -211,7 +213,7 @@
|
||||
.cm-line {
|
||||
line-height: 36px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: var(--card) !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l3-background) !important;
|
||||
@@ -249,8 +251,8 @@
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
|
||||
@@ -284,108 +286,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.add-ons-list {
|
||||
.add-ons-tabs {
|
||||
.add-on-tab-title {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l1-background) !important;
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tab::before {
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
|
||||
.selected-view {
|
||||
color: var(--primary-background) !important;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
|
||||
.selected-view::before {
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.compass-button {
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.having-filter-container {
|
||||
.having-filter-select-container {
|
||||
.having-filter-select-editor {
|
||||
.cm-editor {
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border) !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l1-background) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
background: var(--l1-background) !important;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
|
||||
ul {
|
||||
li {
|
||||
color: var(--l1-foreground) !important;
|
||||
&:hover {
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background: var(--l3-background) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
background-color: var(--l1-background) !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
|
||||
.chip-decorator {
|
||||
background: var(--l3-background) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
|
||||
|
||||
.query-aggregation-container {
|
||||
display: block;
|
||||
|
||||
@@ -26,7 +28,7 @@
|
||||
&.error {
|
||||
.cm-editor {
|
||||
.cm-content {
|
||||
border-color: var(--bg-cherry-500) !important;
|
||||
border-color: var(--danger-background) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +142,7 @@
|
||||
.cm-line {
|
||||
line-height: 36px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: var(--l1-background) !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l3-background) !important;
|
||||
@@ -184,7 +186,7 @@
|
||||
max-width: 300px;
|
||||
|
||||
.query-aggregation-error-message {
|
||||
color: var(--bg-cherry-500);
|
||||
color: var(--danger-background);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
@@ -196,6 +198,7 @@
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
@@ -270,7 +273,7 @@
|
||||
|
||||
.cm-line {
|
||||
::-moz-selection {
|
||||
background: var(--l1-background) !important;
|
||||
background: var(--l2-background) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
|
||||
|
||||
.code-mirror-where-clause {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -30,7 +32,7 @@
|
||||
border-left: none !important;
|
||||
|
||||
&.hasErrors {
|
||||
border-color: var(--bg-cherry-500);
|
||||
border-color: var(--danger-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +41,7 @@
|
||||
&.hasErrors {
|
||||
.cm-editor {
|
||||
.cm-content {
|
||||
border-color: var(--bg-cherry-500);
|
||||
border-color: var(--danger-background);
|
||||
border-top-right-radius: 0px !important;
|
||||
border-bottom-right-radius: 0px !important;
|
||||
}
|
||||
@@ -156,7 +158,7 @@
|
||||
.cm-line {
|
||||
line-height: 34px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: var(--l1-background) !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l3-background) !important;
|
||||
@@ -211,7 +213,7 @@
|
||||
|
||||
.invalid {
|
||||
background-color: color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
|
||||
color: var(--bg-cherry-500);
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.query-validation-status {
|
||||
@@ -232,7 +234,7 @@
|
||||
|
||||
font-size: 12px;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
color: var(--bg-cherry-500);
|
||||
color: var(--danger-background);
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
@@ -454,30 +456,3 @@
|
||||
margin-top: -6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.code-mirror-where-clause {
|
||||
.cm-editor {
|
||||
.cm-tooltip-autocomplete {
|
||||
background: var(--l1-background) !important;
|
||||
border: 1px solid var(--l1-border);
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
::-moz-selection {
|
||||
background: var(--bg-robin-200) !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--bg-robin-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--bg-robin-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
width: 2px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-cherry-500);
|
||||
background: var(--danger-background);
|
||||
}
|
||||
|
||||
.label-true {
|
||||
|
||||
@@ -158,12 +158,12 @@
|
||||
|
||||
mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
color-mix(in srgb, var(--background) 10%, transparent) 0,
|
||||
color-mix(in srgb, var(--l1-background) 10%, transparent) 0,
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
color-mix(in srgb, var(--background) 10%, transparent) 0,
|
||||
color-mix(in srgb, var(--l1-background) 10%, transparent) 0,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/combobox';
|
||||
} from '@signozhq/ui';
|
||||
import { Skeleton, Switch, Tooltip, Typography } from 'antd';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
@@ -200,7 +200,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
setOpen(false);
|
||||
}}
|
||||
isSelected={validQueryIndex === option.value}
|
||||
showCheck={false}
|
||||
>
|
||||
{option.label}
|
||||
</ComboboxItem>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
&__title {
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__container {
|
||||
@@ -19,7 +21,7 @@
|
||||
background-color: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
}
|
||||
@@ -43,14 +45,23 @@
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__button {
|
||||
background: var(--secondary-background);
|
||||
color: var(--secondary-foreground);
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
// TODO: Need to override the button styles for this component due to container styles.
|
||||
// Fix - @aks07
|
||||
|
||||
&__button {
|
||||
margin-top: 12px;
|
||||
color: var(--base-black);
|
||||
background-color: var(--base-white);
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--base-white);
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Typography } from 'antd';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import classNames from 'classnames';
|
||||
import { X } from 'lucide-react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
import './AnnouncementTooltip.styles.scss';
|
||||
|
||||
@@ -46,13 +46,12 @@ function AnnouncementTooltip({
|
||||
className={classNames('announcement-tooltip__container', className)}
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left + 30,
|
||||
left: position.left + 20,
|
||||
}}
|
||||
>
|
||||
<div className="announcement-tooltip__header">
|
||||
<Typography.Text className="announcement-tooltip__title">
|
||||
{title}
|
||||
</Typography.Text>
|
||||
<p className="announcement-tooltip__title">{title}</p>
|
||||
|
||||
<X
|
||||
size={18}
|
||||
onClick={closeTooltip}
|
||||
@@ -61,7 +60,13 @@ function AnnouncementTooltip({
|
||||
</div>
|
||||
<p className="announcement-tooltip__message">{message}</p>
|
||||
<div className="announcement-tooltip__footer">
|
||||
<Button onClick={closeTooltip} className="announcement-tooltip__button">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={closeTooltip}
|
||||
prefix={<Check size={16} />}
|
||||
className="announcement-tooltip__footer__button"
|
||||
>
|
||||
Okay
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--foreground);
|
||||
line-height: var(--line-height-20);
|
||||
@@ -142,6 +142,10 @@
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
&__callout-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__expiry-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui';
|
||||
import { Badge, Button, Callout } from '@signozhq/ui';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyWithKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export interface KeyCreatedPhaseProps {
|
||||
@@ -40,11 +38,13 @@ function KeyCreatedPhase({
|
||||
<Badge color="vanilla">{expiryLabel}</Badge>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
message="Store the key securely. This is the only time it will be displayed."
|
||||
/>
|
||||
<div className="add-key-modal__callout-wrapper">
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
title="Store the key securely. This is the only time it will be displayed."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Control, UseFormRegister } from 'react-hook-form';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Button, Input, ToggleGroup, ToggleGroupItem } from '@signozhq/ui';
|
||||
import { DatePicker } from 'antd';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
@@ -56,11 +54,12 @@ function KeyFormPhase({
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={field.value}
|
||||
onValueChange={(val): void => {
|
||||
onChange={(val): void => {
|
||||
if (val) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
className="add-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
@@ -112,6 +111,7 @@ function KeyFormPhase({
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { DialogWrapper, toast } from '@signozhq/ui';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
invalidateListServiceAccountKeys,
|
||||
@@ -118,12 +117,12 @@ function AddKeyModal(): JSX.Element {
|
||||
copyToClipboard(createdKey.key);
|
||||
setHasCopied(true);
|
||||
setTimeout(() => setHasCopied(false), 2000);
|
||||
toast.success('Key copied to clipboard', { richColors: true });
|
||||
toast.success('Key copied to clipboard');
|
||||
}, [copyToClipboard, createdKey?.key]);
|
||||
|
||||
useEffect(() => {
|
||||
if (copyState.error) {
|
||||
toast.error('Failed to copy key', { richColors: true });
|
||||
toast.error('Failed to copy key');
|
||||
}
|
||||
}, [copyState.error]);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { Button, DialogWrapper, toast } from '@signozhq/ui';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getGetServiceAccountQueryKey,
|
||||
@@ -42,7 +40,7 @@ function DeleteAccountModal(): JSX.Element {
|
||||
} = useDeleteServiceAccount({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
toast.success('Service account deleted', { richColors: true });
|
||||
toast.success('Service account deleted');
|
||||
await setIsDeleteOpen(null);
|
||||
await setAccountId(null);
|
||||
await invalidateListServiceAccounts(queryClient);
|
||||
@@ -70,6 +68,32 @@ function DeleteAccountModal(): JSX.Element {
|
||||
setIsDeleteOpen(null);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<p className="sa-delete-dialog__body">
|
||||
Are you sure you want to delete <strong>{accountName}</strong>? This action
|
||||
cannot be undone. All keys associated with this service account will be
|
||||
permanently removed.
|
||||
</p>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<div className="sa-delete-dialog__footer">
|
||||
<Button variant="solid" color="secondary" onClick={handleCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
@@ -83,28 +107,9 @@ function DeleteAccountModal(): JSX.Element {
|
||||
className="alert-dialog sa-delete-dialog"
|
||||
showCloseButton={false}
|
||||
disableOutsideClick={isErrorModalVisible}
|
||||
footer={footer}
|
||||
>
|
||||
<p className="sa-delete-dialog__body">
|
||||
Are you sure you want to delete <strong>{accountName}</strong>? This action
|
||||
cannot be undone. All keys associated with this service account will be
|
||||
permanently removed.
|
||||
</p>
|
||||
<DialogFooter className="sa-delete-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={handleCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
loading={isDeleting}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{content}
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Control, UseFormRegister } from 'react-hook-form';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { LockKeyhole, Trash2, X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Badge } from '@signozhq/ui';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@signozhq/ui';
|
||||
import { DatePicker } from 'antd';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
@@ -72,11 +75,12 @@ function EditKeyForm({
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={field.value}
|
||||
onValueChange={(val): void => {
|
||||
onChange={(val): void => {
|
||||
if (val) {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
className="edit-key-modal__expiry-toggle"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
@@ -132,25 +136,21 @@ function EditKeyForm({
|
||||
</form>
|
||||
|
||||
<div className="edit-key-modal__footer">
|
||||
<Button
|
||||
type="button"
|
||||
className="edit-key-modal__footer-danger"
|
||||
onClick={onRevokeClick}
|
||||
>
|
||||
<Button variant="ghost" color="destructive" onClick={onRevokeClick}>
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
<div className="edit-key-modal__footer-right">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onClose}>
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
|
||||
form={FORM_ID}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { DialogWrapper, toast } from '@signozhq/ui';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
invalidateListServiceAccountKeys,
|
||||
@@ -72,7 +71,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
toast.success('Key updated successfully', { richColors: true });
|
||||
toast.success('Key updated successfully');
|
||||
await setEditKeyId(null);
|
||||
if (selectedAccountId) {
|
||||
await invalidateListServiceAccountKeys(queryClient, {
|
||||
@@ -96,7 +95,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
|
||||
} = useRevokeServiceAccountKey({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
toast.success('Key revoked successfully');
|
||||
setIsRevokeConfirmOpen(false);
|
||||
await setEditKeyId(null);
|
||||
if (selectedAccountId) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { KeyRound, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Skeleton, Table, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -96,7 +96,7 @@ function buildColumns({
|
||||
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
size="sm"
|
||||
color="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={(e): void => {
|
||||
@@ -177,8 +177,8 @@ function KeysTab({
|
||||
</a>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="keys-tab__learn-more"
|
||||
variant="link"
|
||||
color="primary"
|
||||
onClick={async (): Promise<void> => {
|
||||
await setIsAddKeyOpen(true);
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LockKeyhole } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Badge } from '@signozhq/ui';
|
||||
import { Badge, Input } from '@signozhq/ui';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DialogFooter, DialogWrapper } from '@signozhq/dialog';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { Button, DialogWrapper, toast } from '@signozhq/ui';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
getListServiceAccountKeysQueryKey,
|
||||
@@ -36,7 +34,7 @@ export function RevokeKeyContent({
|
||||
Revoking this key will permanently invalidate it. Any systems using this key
|
||||
will lose access immediately.
|
||||
</p>
|
||||
<DialogFooter className="delete-dialog__footer">
|
||||
<div className="delete-dialog__footer">
|
||||
<Button variant="solid" color="secondary" size="sm" onClick={onCancel}>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
@@ -51,7 +49,7 @@ export function RevokeKeyContent({
|
||||
<Trash2 size={12} />
|
||||
Revoke Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +77,7 @@ function RevokeKeyModal(): JSX.Element {
|
||||
} = useRevokeServiceAccountKey({
|
||||
mutation: {
|
||||
onSuccess: async () => {
|
||||
toast.success('Key revoked successfully', { richColors: true });
|
||||
toast.success('Key revoked successfully');
|
||||
await setRevokeKeyId(null);
|
||||
if (accountId) {
|
||||
await invalidateListServiceAccountKeys(queryClient, { id: accountId });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { ChevronDown, ChevronUp, CircleAlert, RotateCw } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
@@ -40,9 +40,9 @@ function SaveErrorItem({
|
||||
</span>
|
||||
{onRetry && !isRetrying && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
color="none"
|
||||
aria-label="Retry"
|
||||
size="xs"
|
||||
onClick={async (e): Promise<void> => {
|
||||
e.stopPropagation();
|
||||
setIsRetrying(true);
|
||||
|
||||
@@ -5,31 +5,21 @@
|
||||
margin-left: var(--margin-2);
|
||||
}
|
||||
|
||||
&__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 48px);
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--padding-3) var(--padding-4) var(--padding-2) var(--padding-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__tab-group {
|
||||
[data-slot='toggle-group'] {
|
||||
height: 32px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-slot='toggle-group-item'] {
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: transparent;
|
||||
@@ -40,6 +30,7 @@
|
||||
padding: 0 var(--padding-7);
|
||||
gap: var(--spacing-3);
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
|
||||
&:first-child {
|
||||
border-left: none;
|
||||
@@ -88,7 +79,7 @@
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--padding-5) var(--padding-4);
|
||||
padding-top: var(--padding-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
@@ -112,14 +103,11 @@
|
||||
}
|
||||
|
||||
&__footer {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
border-top: 1px solid var(--secondary);
|
||||
background: var(--card);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__keys-pagination {
|
||||
@@ -302,7 +290,7 @@
|
||||
|
||||
&__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--bg-cherry-500);
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
&__title {
|
||||
@@ -310,7 +298,7 @@
|
||||
min-width: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--bg-cherry-500);
|
||||
color: var(--danger-background);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.06px;
|
||||
text-align: left;
|
||||
@@ -587,6 +575,5 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--margin-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { DrawerWrapper } from '@signozhq/drawer';
|
||||
import { Key, LayoutGrid, Plus, Trash2, X } from '@signozhq/icons';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import {
|
||||
Button,
|
||||
DrawerWrapper,
|
||||
toast,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@signozhq/ui';
|
||||
import { Pagination, Skeleton } from 'antd';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
@@ -331,7 +334,6 @@ function ServiceAccountDrawer({
|
||||
setSaveErrors(errors);
|
||||
} else {
|
||||
toast.success('Service account updated successfully', {
|
||||
richColors: true,
|
||||
position: 'top-right',
|
||||
});
|
||||
onSuccess({ closeDrawer: false });
|
||||
@@ -379,7 +381,7 @@ function ServiceAccountDrawer({
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={activeTab}
|
||||
onValueChange={(val): void => {
|
||||
onChange={(val): void => {
|
||||
if (val) {
|
||||
setActiveTab(val as ServiceAccountDrawerTab);
|
||||
if (val !== ServiceAccountDrawerTab.Keys) {
|
||||
@@ -471,69 +473,64 @@ function ServiceAccountDrawer({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className="sa-drawer__footer">
|
||||
{activeTab === ServiceAccountDrawerTab.Keys ? (
|
||||
<Pagination
|
||||
current={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={keys.length}
|
||||
showTotal={(total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-drawer__pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-drawer__pagination-total"> of {total}</span>
|
||||
</>
|
||||
)}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={(page): void => {
|
||||
void setKeysPage(page);
|
||||
}}
|
||||
className="sa-drawer__keys-pagination"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!isDeleted && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="sa-drawer__footer-btn"
|
||||
onClick={(): void => {
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
const footer = (
|
||||
<div className="sa-drawer__footer">
|
||||
{activeTab === ServiceAccountDrawerTab.Keys ? (
|
||||
<Pagination
|
||||
current={keysPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={keys.length}
|
||||
showTotal={(total: number, range: number[]): JSX.Element => (
|
||||
<>
|
||||
<span className="sa-drawer__pagination-range">
|
||||
{range[0]} — {range[1]}
|
||||
</span>
|
||||
<span className="sa-drawer__pagination-total"> of {total}</span>
|
||||
</>
|
||||
)}
|
||||
showSizeChanger={false}
|
||||
hideOnSinglePage
|
||||
onChange={(page): void => {
|
||||
void setKeysPage(page);
|
||||
}}
|
||||
className="sa-drawer__keys-pagination"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{!isDeleted && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="destructive"
|
||||
onClick={(): void => {
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete Service Account
|
||||
</Button>
|
||||
)}
|
||||
{!isDeleted && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button variant="solid" color="secondary" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{!isDeleted && (
|
||||
<div className="sa-drawer__footer-right">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -547,14 +544,15 @@ function ServiceAccountDrawer({
|
||||
}
|
||||
}}
|
||||
direction="right"
|
||||
type="panel"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
allowOutsideClick
|
||||
header={{ title: 'Service Account Details' }}
|
||||
content={drawerContent}
|
||||
title="Service Account Details"
|
||||
className="sa-drawer"
|
||||
/>
|
||||
width="wide"
|
||||
footer={footer}
|
||||
>
|
||||
{drawerContent}
|
||||
</DrawerWrapper>
|
||||
|
||||
<DeleteAccountModal />
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import AddKeyModal from '../AddKeyModal';
|
||||
|
||||
@@ -117,10 +123,7 @@ describe('AddKeyModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('snz_abc123xyz456secret');
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key copied to clipboard',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Key copied to clipboard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,11 +131,9 @@ describe('AddKeyModal', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
|
||||
await screen.findByRole('dialog', { name: /Add a New Key/i });
|
||||
const dialog = await screen.findByRole('dialog', { name: /Add a New Key/i });
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Add a New Key/i }),
|
||||
).not.toBeInTheDocument();
|
||||
await waitForElementToBeRemoved(dialog);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,9 +29,14 @@ function renderModal(
|
||||
account: 'sa-1',
|
||||
'edit-key': 'key-1',
|
||||
},
|
||||
onUrlUpdate?: jest.Mock,
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={searchParams}
|
||||
hasMemory
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
>
|
||||
<EditKeyModal keyItem={keyItem} />
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
@@ -82,10 +87,7 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Save Changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key updated successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Key updated successfully');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -97,14 +99,31 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
|
||||
it('cancel clears edit-key param and closes modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
renderModal();
|
||||
const onUrlUpdate = jest.fn();
|
||||
renderModal(mockKey, undefined, onUrlUpdate);
|
||||
|
||||
await screen.findByDisplayValue('Original Key Name');
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const latestUrlUpdate =
|
||||
onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]?.[0];
|
||||
expect(latestUrlUpdate).toEqual(
|
||||
expect.objectContaining({
|
||||
queryString: expect.any(String),
|
||||
}),
|
||||
);
|
||||
expect(latestUrlUpdate.queryString).toContain('account=sa-1');
|
||||
expect(latestUrlUpdate.queryString).not.toContain('edit-key=');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: /Edit Key Details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('revoke flow: clicking Revoke Key shows confirmation inside same dialog', async () => {
|
||||
@@ -136,10 +155,7 @@ describe('EditKeyModal (URL-controlled)', () => {
|
||||
await user.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key revoked successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Key revoked successfully');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -164,10 +164,7 @@ describe('KeysTab', () => {
|
||||
await user.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalledWith(
|
||||
'Key revoked successfully',
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Key revoked successfully');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,18 +6,23 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
jest.mock('@signozhq/drawer', () => ({
|
||||
DrawerWrapper: ({
|
||||
content,
|
||||
open,
|
||||
}: {
|
||||
content?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null => (open ? <div>{content}</div> : null),
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui', () => ({
|
||||
...jest.requireActual('@signozhq/ui'),
|
||||
DrawerWrapper: ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
open: boolean;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
.ant-table-thead {
|
||||
> tr > th,
|
||||
> tr > td {
|
||||
background: var(--background);
|
||||
background: var(--l1-background);
|
||||
font-size: var(--paragraph-small-600-font-size);
|
||||
font-weight: var(--paragraph-small-600-font-weight);
|
||||
line-height: var(--paragraph-small-600-line-height);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
gap: 20px;
|
||||
padding: 8px 12px;
|
||||
|
||||
background: var(--background);
|
||||
background: var(--l1-background);
|
||||
color: var(--l2-foreground);
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
&__error {
|
||||
font-size: 0.75rem;
|
||||
color: var(--bg-cherry-500);
|
||||
color: var(--danger-background);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--bg-cherry-500);
|
||||
background: var(--danger-background);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { SpinerStyle } from './styles';
|
||||
function Spinner({ size, tip, height, style }: SpinnerProps): JSX.Element {
|
||||
return (
|
||||
<SpinerStyle height={height} style={style}>
|
||||
<Spin spinning size={size} tip={tip} indicator={<LoadingOutlined spin />} />
|
||||
<Spin spinning size={size} tip={tip} indicator={<LoadingOutlined spin />}>
|
||||
<div />
|
||||
</Spin>
|
||||
</SpinerStyle>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.warning-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// === SECTION: Summary (Top)
|
||||
&__summary-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&__summary {
|
||||
|
||||
@@ -161,10 +161,11 @@ describe('CmdKPalette', () => {
|
||||
});
|
||||
|
||||
test('clicking a navigation item calls history.push with correct route', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const homeItem = screen.getByText(HOME_LABEL);
|
||||
await userEvent.click(homeItem);
|
||||
await user.click(homeItem);
|
||||
|
||||
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
|
||||
});
|
||||
@@ -194,10 +195,11 @@ describe('CmdKPalette', () => {
|
||||
});
|
||||
|
||||
test('closing the palette via handleInvoke sets open to false', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CmdKPalette userRole="ADMIN" />);
|
||||
|
||||
const dashItem = screen.getByText('Go to Dashboards');
|
||||
await userEvent.click(dashItem);
|
||||
await user.click(dashItem);
|
||||
|
||||
// last call from handleInvoke should set open to false
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
.cmdk-item-light:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--background) !important;
|
||||
background-color: var(--l1-background) !important;
|
||||
}
|
||||
|
||||
.cmdk-item-light[data-selected='true'] {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandShortcut,
|
||||
} from '@signozhq/command';
|
||||
} from '@signozhq/ui';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import history from 'lib/history';
|
||||
|
||||
@@ -9,4 +9,6 @@ export enum FeatureKeys {
|
||||
ANOMALY_DETECTION = 'anomaly_detection',
|
||||
ONBOARDING_V3 = 'onboarding_v3',
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
/** When active, AI assistant UI, routes, and integrations are available. */
|
||||
AI_ASSISTANT_ENABLED = 'ai_assistant_enabled',
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ const ROUTES = {
|
||||
HOME_PAGE: '/',
|
||||
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
|
||||
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
|
||||
AI_ASSISTANT: '/ai-assistant/:conversationId',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
1852
frontend/src/container/AIAssistant/AIAssistant.styles.scss
Normal file
1852
frontend/src/container/AIAssistant/AIAssistant.styles.scss
Normal file
File diff suppressed because it is too large
Load Diff
97
frontend/src/container/AIAssistant/AIAssistantDrawer.tsx
Normal file
97
frontend/src/container/AIAssistant/AIAssistantDrawer.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Drawer, Tooltip } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Maximize2, MessageSquare, Plus, X } from 'lucide-react';
|
||||
|
||||
import ConversationView from './ConversationView';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
export default function AIAssistantDrawer(): JSX.Element {
|
||||
const history = useHistory();
|
||||
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
startNewConversation();
|
||||
}, [startNewConversation]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={isDrawerOpen}
|
||||
onClose={closeDrawer}
|
||||
placement="right"
|
||||
width={420}
|
||||
className="ai-assistant-drawer"
|
||||
// Suppress default close button — we render our own header
|
||||
closeIcon={null}
|
||||
title={
|
||||
<div className="ai-assistant-drawer__header">
|
||||
<div className="ai-assistant-drawer__title">
|
||||
<MessageSquare size={16} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className="ai-assistant-drawer__actions">
|
||||
<Tooltip title="New conversation">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-assistant-drawer__action-btn"
|
||||
onClick={handleNewConversation}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-assistant-drawer__action-btn"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-assistant-drawer__action-btn"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activeConversationId ? (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
) : null}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
222
frontend/src/container/AIAssistant/AIAssistantModal.tsx
Normal file
222
frontend/src/container/AIAssistant/AIAssistantModal.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Eraser, History, Maximize2, Minus, Plus, X } from 'lucide-react';
|
||||
|
||||
import AIAssistantIcon from './components/AIAssistantIcon';
|
||||
import HistorySidebar from './components/HistorySidebar';
|
||||
import ConversationView from './ConversationView';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
/**
|
||||
* Global floating modal for the AI Assistant.
|
||||
*
|
||||
* - Triggered by Cmd+P (Mac) / Ctrl+P (Windows/Linux)
|
||||
* - Escape or the × button fully closes it
|
||||
* - The − (minimize) button collapses to the side panel
|
||||
* - Mounted once in AppLayout; always in the DOM, conditionally visible
|
||||
*/
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function AIAssistantModal(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const openModal = useAIAssistantStore((s) => s.openModal);
|
||||
const closeModal = useAIAssistantStore((s) => s.closeModal);
|
||||
const minimizeModal = useAIAssistantStore((s) => s.minimizeModal);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
const clearConversation = useAIAssistantStore((s) => s.clearConversation);
|
||||
|
||||
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
// Cmd+P (Mac) / Ctrl+P (Win/Linux) — toggle modal
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'p') {
|
||||
// Don't intercept Cmd+P inside input/textarea — those are for the user
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault(); // stop browser print dialog
|
||||
if (isOpen) {
|
||||
closeModal();
|
||||
} else {
|
||||
openModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape — close modal
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, openModal, closeModal]);
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeModal, history]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
}, [startNewConversation]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (activeConversationId) {
|
||||
clearConversation(activeConversationId);
|
||||
}
|
||||
}, [activeConversationId, clearConversation]);
|
||||
|
||||
const handleHistorySelect = useCallback(() => {
|
||||
setShowHistory(false);
|
||||
}, []);
|
||||
|
||||
const handleMinimize = useCallback(() => {
|
||||
minimizeModal();
|
||||
setShowHistory(false);
|
||||
}, [minimizeModal]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Only close when clicking the backdrop itself, not the modal card
|
||||
if (e.target === e.currentTarget) {
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
[closeModal],
|
||||
);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="ai-modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="AI Assistant"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="ai-modal">
|
||||
{/* Header */}
|
||||
<div className="ai-modal__header">
|
||||
<div className="ai-modal__title">
|
||||
<AIAssistantIcon size={16} />
|
||||
<span>AI Assistant</span>
|
||||
<kbd className="ai-modal__shortcut">⌘P</kbd>
|
||||
</div>
|
||||
|
||||
<div className="ai-modal__actions">
|
||||
<Tooltip title={showHistory ? 'Back to chat' : 'Chat history'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle history"
|
||||
className={showHistory ? 'ai-panel-btn--active' : ''}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Clear chat">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClear}
|
||||
disabled={!activeConversationId || showHistory}
|
||||
aria-label="Clear chat"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Minimize to side panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleMinimize}
|
||||
aria-label="Minimize to side panel"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={closeModal}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="ai-modal__body">
|
||||
{showHistory ? (
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
179
frontend/src/container/AIAssistant/AIAssistantPanel.tsx
Normal file
179
frontend/src/container/AIAssistant/AIAssistantPanel.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Eraser, History, Maximize2, Plus, X } from 'lucide-react';
|
||||
|
||||
import AIAssistantIcon from './components/AIAssistantIcon';
|
||||
import HistorySidebar from './components/HistorySidebar';
|
||||
import ConversationView from './ConversationView';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
export default function AIAssistantPanel(): JSX.Element | null {
|
||||
const history = useHistory();
|
||||
const { pathname } = useLocation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isFullScreenPage = !!matchPath(pathname, {
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
});
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
const clearConversation = useAIAssistantStore((s) => s.clearConversation);
|
||||
|
||||
const handleExpand = useCallback(() => {
|
||||
if (!activeConversationId) {
|
||||
return;
|
||||
}
|
||||
closeDrawer();
|
||||
history.push(
|
||||
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
|
||||
);
|
||||
}, [activeConversationId, closeDrawer, history]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
startNewConversation();
|
||||
setShowHistory(false);
|
||||
}, [startNewConversation]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (activeConversationId) {
|
||||
clearConversation(activeConversationId);
|
||||
}
|
||||
}, [activeConversationId, clearConversation]);
|
||||
|
||||
// When user picks a conversation from history, close the sidebar
|
||||
const handleHistorySelect = useCallback(() => {
|
||||
setShowHistory(false);
|
||||
}, []);
|
||||
|
||||
// ── Resize logic ──────────────────────────────────────────────────────────
|
||||
const [panelWidth, setPanelWidth] = useState(380);
|
||||
const dragStartX = useRef(0);
|
||||
const dragStartWidth = useRef(0);
|
||||
|
||||
const handleResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStartX.current = e.clientX;
|
||||
dragStartWidth.current = panelWidth;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent): void => {
|
||||
// Panel is on the right; dragging left (lower clientX) increases width
|
||||
const delta = dragStartX.current - ev.clientX;
|
||||
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
|
||||
setPanelWidth(next);
|
||||
};
|
||||
|
||||
const onMouseUp = (): void => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
},
|
||||
[panelWidth],
|
||||
);
|
||||
|
||||
if (!isOpen || isFullScreenPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-assistant-panel" style={{ width: panelWidth }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="ai-assistant-panel__resize-handle"
|
||||
onMouseDown={handleResizeMouseDown}
|
||||
/>
|
||||
<div className="ai-assistant-panel__header">
|
||||
<div className="ai-assistant-panel__title">
|
||||
<AIAssistantIcon size={18} />
|
||||
<span>AI Assistant</span>
|
||||
</div>
|
||||
|
||||
<div className="ai-assistant-panel__actions">
|
||||
<Tooltip title={showHistory ? 'Back to chat' : 'Chat history'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(): void => setShowHistory((v) => !v)}
|
||||
aria-label="Toggle history"
|
||||
className={showHistory ? 'ai-panel-btn--active' : ''}
|
||||
>
|
||||
<History size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Clear chat">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleClear}
|
||||
disabled={!activeConversationId || showHistory}
|
||||
aria-label="Clear chat"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistory ? (
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/container/AIAssistant/AIAssistantTrigger.tsx
Normal file
43
frontend/src/container/AIAssistant/AIAssistantTrigger.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Tooltip } from '@signozhq/ui';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Bot } from 'lucide-react';
|
||||
|
||||
import {
|
||||
openAIAssistant,
|
||||
useAIAssistantStore,
|
||||
} from './store/useAIAssistantStore';
|
||||
|
||||
import './AIAssistant.styles.scss';
|
||||
|
||||
/**
|
||||
* Floating action button anchored to the bottom-right of the content area.
|
||||
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
|
||||
*/
|
||||
export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
const { pathname } = useLocation();
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
|
||||
|
||||
const isFullScreenPage = !!matchPath(pathname, {
|
||||
path: ROUTES.AI_ASSISTANT,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title="AI Assistant">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-trigger"
|
||||
onClick={openAIAssistant}
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
<Bot size={20} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
81
frontend/src/container/AIAssistant/ConversationView.tsx
Normal file
81
frontend/src/container/AIAssistant/ConversationView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import ChatInput from './components/ChatInput';
|
||||
import VirtualizedMessages from './components/VirtualizedMessages';
|
||||
import { useAIAssistantStore } from './store/useAIAssistantStore';
|
||||
import { MessageAttachment } from './types';
|
||||
|
||||
interface ConversationViewProps {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
export default function ConversationView({
|
||||
conversationId,
|
||||
}: ConversationViewProps): JSX.Element {
|
||||
const conversation = useAIAssistantStore(
|
||||
(s) => s.conversations[conversationId],
|
||||
);
|
||||
const isStreamingHere = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
const isLoadingThread = useAIAssistantStore((s) => s.isLoadingThread);
|
||||
const pendingApprovalHere = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingApproval ?? null,
|
||||
);
|
||||
const pendingClarificationHere = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingClarification ?? null,
|
||||
);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
|
||||
|
||||
const handleSend = useCallback(
|
||||
(text: string, attachments?: MessageAttachment[]) => {
|
||||
sendMessage(text, attachments);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelStream(conversationId);
|
||||
}, [cancelStream, conversationId]);
|
||||
|
||||
const messages = conversation?.messages ?? [];
|
||||
const inputDisabled =
|
||||
isStreamingHere ||
|
||||
isLoadingThread ||
|
||||
Boolean(pendingApprovalHere) ||
|
||||
Boolean(pendingClarificationHere);
|
||||
|
||||
if (isLoadingThread && messages.length === 0) {
|
||||
return (
|
||||
<div className="ai-conversation">
|
||||
<div className="ai-conversation__loading">
|
||||
<Loader2 size={20} className="ai-history__spinner" />
|
||||
Loading conversation…
|
||||
</div>
|
||||
<div className="ai-conversation__input-wrapper">
|
||||
<ChatInput onSend={handleSend} disabled />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-conversation">
|
||||
<VirtualizedMessages
|
||||
conversationId={conversationId}
|
||||
messages={messages}
|
||||
isStreaming={isStreamingHere}
|
||||
/>
|
||||
<div className="ai-conversation__input-wrapper">
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onCancel={handleCancel}
|
||||
disabled={inputDisabled}
|
||||
isStreaming={isStreamingHere}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
649
frontend/src/container/AIAssistant/PAGE_ACTIONS_DESIGN.md
Normal file
649
frontend/src/container/AIAssistant/PAGE_ACTIONS_DESIGN.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# Page-Aware AI Action System — Technical Design
|
||||
|
||||
**Status:** Draft
|
||||
**Author:** AI Assistant
|
||||
**Created:** 2026-03-31
|
||||
**Scope:** Frontend — AI Assistant integration with page-specific actions
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The Page-Aware AI Action System extends the AI Assistant so that it can understand what page the user is currently on, read the page's live state (active filters, time range, selected entities, etc.), and execute actions available on that page — all through a natural-language conversation.
|
||||
|
||||
### Goals
|
||||
|
||||
- Let users query, filter, and navigate each SigNoz page by talking to the AI
|
||||
- Let users create and modify entities (dashboards, alerts, saved views) via the AI
|
||||
- Keep page-specific wiring isolated and co-located with each page — not inside the AI core
|
||||
- Zero-friction adoption: adding AI support to a new page is a single `usePageActions(...)` call
|
||||
- Prevent the AI from silently mutating state — every action requires explicit user confirmation
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Backend AI model training or fine-tuning
|
||||
- Real-time data streaming inside the AI chat (charts already handle that via existing blocks)
|
||||
- Cross-session memory of user preferences (deferred to a future persistent-context system)
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Diagram
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ Active Page (e.g. LogsExplorer) │ │
|
||||
│ │ │ │
|
||||
│ │ usePageActions('logs-explorer', [...actions]) │ │
|
||||
│ │ │ registers on mount │ │
|
||||
│ │ │ unregisters on unmount │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌──────────────────┐ │ │
|
||||
│ │ │ PageActionRegistry│ ◄── singleton │ │
|
||||
│ │ │ Map<id, Action> │ │ │
|
||||
│ │ └────────┬─────────┘ │ │
|
||||
│ └───────────┼─────────────────────────────────────┘ │
|
||||
│ │ getAll() + context snapshot │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ AI Assistant (Drawer / Full-page) │ │
|
||||
│ │ │ │
|
||||
│ │ sendMessage() │ │
|
||||
│ │ ├── builds [PAGE_CONTEXT] block from registry │ │
|
||||
│ │ ├── appends user text │ │
|
||||
│ │ └── sends to API ──────────────────────────────────► │ │
|
||||
│ │ AI Backend / Mock │ │
|
||||
│ │ ◄── streaming response │ │
|
||||
│ │ │ │
|
||||
│ │ MessageBubble │ │
|
||||
│ │ └── RichCodeBlock detects ```ai-action │ │
|
||||
│ │ └── ActionBlock │ │
|
||||
│ │ ├── renders description + param preview │ │
|
||||
│ │ ├── Accept → PageActionRegistry.execute() │ │
|
||||
│ │ └── Reject → no-op │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
### 3.1 `PageAction<TParams>`
|
||||
|
||||
The descriptor for a single action a page exposes to the AI.
|
||||
|
||||
```typescript
|
||||
interface PageAction<TParams = Record<string, unknown>> {
|
||||
/**
|
||||
* Stable identifier, dot-namespaced by page.
|
||||
* e.g. "logs.runQuery", "dashboard.createPanel", "alert.save"
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Natural-language description sent verbatim in the page context block.
|
||||
* The AI uses this to decide which action to invoke.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* JSON Schema (draft-07) describing the parameters this action accepts.
|
||||
* Sent to the AI so it can generate structurally valid calls.
|
||||
*/
|
||||
parameters: JSONSchemaObject;
|
||||
|
||||
/**
|
||||
* Performs the action. Resolves with a result the AI can narrate back to
|
||||
* the user. Rejects if the action cannot be completed.
|
||||
*/
|
||||
execute: (params: TParams) => Promise<ActionResult>;
|
||||
|
||||
/**
|
||||
* Optional snapshot of the current page state.
|
||||
* Called at message-send time so the AI has fresh context.
|
||||
* Return value is JSON-serialised into the [PAGE_CONTEXT] block.
|
||||
*/
|
||||
getContext?: () => unknown;
|
||||
}
|
||||
|
||||
interface ActionResult {
|
||||
/** Short human-readable outcome: "Query updated with 2 new filters." */
|
||||
summary: string;
|
||||
/** Optional structured data the AI block can display (e.g. a new URL) */
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type JSONSchemaObject = {
|
||||
type: 'object';
|
||||
properties: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 `PageActionDescriptor`
|
||||
|
||||
A lightweight, serialisable version of `PageAction` — safe to include in the API payload (no function references).
|
||||
|
||||
```typescript
|
||||
interface PageActionDescriptor {
|
||||
id: string;
|
||||
description: string;
|
||||
parameters: JSONSchemaObject;
|
||||
context?: unknown; // snapshot from getContext()
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 `AIActionBlock`
|
||||
|
||||
The JSON payload the AI emits inside an ` ```ai-action ``` ` fenced block when it wants to invoke an action.
|
||||
|
||||
```typescript
|
||||
interface AIActionBlock {
|
||||
/** Must match a registered PageAction.id */
|
||||
actionId: string;
|
||||
|
||||
/**
|
||||
* One-sentence explanation of what the action will do.
|
||||
* Displayed in the confirmation card.
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Parameters the AI chose for this action.
|
||||
* Validated against the action's JSON Schema before execute() is called.
|
||||
*/
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. PageActionRegistry
|
||||
|
||||
A module-level singleton (like `BlockRegistry`). Keeps a flat `Map<id, PageAction>` so look-up is O(1). Supports batch register/unregister keyed by a `pageId` so a page can remove all its actions at once on unmount.
|
||||
|
||||
```
|
||||
src/container/AIAssistant/pageActions/PageActionRegistry.ts
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
```typescript
|
||||
const PageActionRegistry = {
|
||||
/** Register a set of actions under a page scope key. */
|
||||
register(pageId: string, actions: PageAction[]): void;
|
||||
|
||||
/** Remove all actions registered under a page scope key. */
|
||||
unregister(pageId: string): void;
|
||||
|
||||
/** Look up a single action by its dot-namespaced id. */
|
||||
get(actionId: string): PageAction | undefined;
|
||||
|
||||
/**
|
||||
* Return serialisable descriptors for all currently registered actions,
|
||||
* with context snapshots already collected.
|
||||
*/
|
||||
snapshot(): PageActionDescriptor[];
|
||||
};
|
||||
```
|
||||
|
||||
### Internal structure
|
||||
|
||||
```typescript
|
||||
// pageId → action[] (for clean unregister)
|
||||
const _byPage = new Map<string, PageAction[]>();
|
||||
|
||||
// actionId → action (for O(1) lookup at execute time)
|
||||
const _byId = new Map<string, PageAction>();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `usePageActions` Hook
|
||||
|
||||
Pages call this hook to register their actions declaratively. React lifecycle handles cleanup.
|
||||
|
||||
```
|
||||
src/container/AIAssistant/pageActions/usePageActions.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
function usePageActions(pageId: string, actions: PageAction[]): void {
|
||||
useEffect(() => {
|
||||
PageActionRegistry.register(pageId, actions);
|
||||
return () => PageActionRegistry.unregister(pageId);
|
||||
// Re-register if action definitions change (e.g. new callbacks after query update)
|
||||
}, [pageId, actions]);
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** action factories (see §8) memoize with `useMemo` so that the `actions` array reference is stable — preventing unnecessary re-registrations.
|
||||
|
||||
---
|
||||
|
||||
## 6. Context Injection in `sendMessage`
|
||||
|
||||
Before every outgoing message, the AI store reads the registry and prepends a machine-readable context block to the API payload content. This block is **never stored in the conversation** (not visible in the message list) — it exists only in the network payload.
|
||||
|
||||
```
|
||||
[PAGE_CONTEXT]
|
||||
page: logs-explorer
|
||||
actions:
|
||||
- id: logs.runQuery
|
||||
description: "Run the current log query with updated filters or time range"
|
||||
params: { filters: TagFilter[], timeRange?: string }
|
||||
- id: logs.saveView
|
||||
description: "Save the current query as a named view"
|
||||
params: { name: string }
|
||||
state:
|
||||
filters: [{ key: "level", op: "=", value: "error" }]
|
||||
timeRange: "Last 15 minutes"
|
||||
panelType: "list"
|
||||
[/PAGE_CONTEXT]
|
||||
|
||||
{user's message}
|
||||
```
|
||||
|
||||
### Implementation in `useAIAssistantStore.sendMessage`
|
||||
|
||||
```typescript
|
||||
// Build context prefix from registry
|
||||
function buildContextPrefix(): string {
|
||||
const descriptors = PageActionRegistry.snapshot();
|
||||
if (descriptors.length === 0) return '';
|
||||
|
||||
const actionLines = descriptors.map(a =>
|
||||
` - id: ${a.id}\n description: "${a.description}"\n params: ${JSON.stringify(a.parameters.properties)}`
|
||||
).join('\n');
|
||||
|
||||
const contextLines = descriptors
|
||||
.filter(a => a.context !== undefined)
|
||||
.map(a => ` ${a.id}: ${JSON.stringify(a.context)}`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'[PAGE_CONTEXT]',
|
||||
'actions:',
|
||||
actionLines,
|
||||
contextLines ? 'state:' : '',
|
||||
contextLines,
|
||||
'[/PAGE_CONTEXT]',
|
||||
'',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
// In sendMessage, when building the API payload:
|
||||
const payload = {
|
||||
conversationId: activeConversationId,
|
||||
messages: history.map((m, i) => {
|
||||
const content = (i === history.length - 1 && m.role === 'user')
|
||||
? buildContextPrefix() + m.content
|
||||
: m.content;
|
||||
return { role: m.role, content };
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
The displayed message in the UI always shows only `m.content` (the user's raw text). The context prefix only exists in the wire payload.
|
||||
|
||||
---
|
||||
|
||||
## 7. `ActionBlock` Component
|
||||
|
||||
Registered as `BlockRegistry.register('action', ActionBlock)`.
|
||||
|
||||
```
|
||||
src/container/AIAssistant/components/blocks/ActionBlock.tsx
|
||||
```
|
||||
|
||||
### Render states
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ⚡ Suggested Action │
|
||||
│ │
|
||||
│ "Filter logs for ERROR level from payment-service" │
|
||||
│ │
|
||||
│ Parameters: │
|
||||
│ • level = ERROR │
|
||||
│ • service.name = payment-service │
|
||||
│ │
|
||||
│ [ Apply ] [ Dismiss ] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
── After Apply ──
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ✓ Applied: "Filter logs for ERROR level from │
|
||||
│ payment-service" │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
── After error ──
|
||||
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ ✗ Failed: "Action 'logs.runQuery' is not │
|
||||
│ available on the current page." │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Execution flow
|
||||
|
||||
1. Parse `AIActionBlock` JSON from the fenced block content
|
||||
2. Validate `parameters` against the action's JSON Schema (fail fast with a clear error)
|
||||
3. Look up `PageActionRegistry.get(actionId)` — if missing, show "not available" state
|
||||
4. On Accept: call `action.execute(parameters)`, show loading spinner
|
||||
5. On success: show summary from `ActionResult.summary`, call `markBlockAnswered(messageId, 'applied')`
|
||||
6. On failure: show error, allow retry
|
||||
7. On Dismiss: call `markBlockAnswered(messageId, 'dismissed')`
|
||||
|
||||
Like `ConfirmBlock` and `InteractiveQuestion`, `ActionBlock` uses `MessageContext` to get `messageId` and `answeredBlocks` from the store to persist its answered state across remounts.
|
||||
|
||||
---
|
||||
|
||||
## 8. Page Action Factories
|
||||
|
||||
Each page co-locates its action definitions in an `aiActions.ts` file. Factories are functions that close over the page's live state and handlers, so the `execute` callbacks always operate on current data.
|
||||
|
||||
### Example: `src/pages/LogsExplorer/aiActions.ts`
|
||||
|
||||
```typescript
|
||||
export function logsRunQueryAction(deps: {
|
||||
handleRunQuery: () => void;
|
||||
updateQueryFilters: (filters: TagFilterItem[]) => void;
|
||||
currentQuery: Query;
|
||||
globalTime: GlobalReducer;
|
||||
}): PageAction {
|
||||
return {
|
||||
id: 'logs.runQuery',
|
||||
description: 'Update the active log filters and run the query',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filters: {
|
||||
type: 'array',
|
||||
description: 'Replacement filter list. Each item has key, op, value.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: { type: 'string' },
|
||||
op: { type: 'string', enum: ['=', '!=', 'IN', 'NOT_IN', 'CONTAINS', 'NOT_CONTAINS'] },
|
||||
value: { type: 'string' },
|
||||
},
|
||||
required: ['key', 'op', 'value'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: async ({ filters }) => {
|
||||
deps.updateQueryFilters(filters as TagFilterItem[]);
|
||||
deps.handleRunQuery();
|
||||
return { summary: `Query updated with ${filters.length} filter(s) and re-run.` };
|
||||
},
|
||||
getContext: () => ({
|
||||
filters: deps.currentQuery.builder.queryData[0]?.filters?.items ?? [],
|
||||
timeRange: deps.globalTime.selectedTime,
|
||||
panelType: 'list',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function logsSaveViewAction(deps: {
|
||||
saveView: (name: string) => Promise<void>;
|
||||
}): PageAction {
|
||||
return {
|
||||
id: 'logs.saveView',
|
||||
description: 'Save the current log query as a named view',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', description: 'View name' } },
|
||||
required: ['name'],
|
||||
},
|
||||
execute: async ({ name }) => {
|
||||
await deps.saveView(name as string);
|
||||
return { summary: `View "${name}" saved.` };
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in the page component
|
||||
|
||||
```typescript
|
||||
// src/pages/LogsExplorer/index.tsx
|
||||
|
||||
const { handleRunQuery, updateQueryFilters, currentQuery } = useQueryBuilder();
|
||||
const globalTime = useSelector((s) => s.globalTime);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
logsRunQueryAction({ handleRunQuery, updateQueryFilters, currentQuery, globalTime }),
|
||||
logsSaveViewAction({ saveView }),
|
||||
logsExportToDashboardAction({ exportToDashboard }),
|
||||
],
|
||||
[handleRunQuery, updateQueryFilters, currentQuery, globalTime, saveView, exportToDashboard],
|
||||
);
|
||||
|
||||
usePageActions('logs-explorer', actions);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Action Catalogue (Initial Scope)
|
||||
|
||||
### 9.1 Logs Explorer
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `logs.runQuery` | Update filters and run the log query | `filters: TagFilterItem[]` |
|
||||
| `logs.addFilter` | Append a single filter to the existing set | `key, op, value` |
|
||||
| `logs.changeView` | Switch between list / timeseries / table | `view: 'list' \| 'timeseries' \| 'table'` |
|
||||
| `logs.saveView` | Save current query as a named view | `name: string` |
|
||||
| `logs.exportToDashboard` | Add current query as a panel to a dashboard | `dashboardId?: string, panelTitle?: string` |
|
||||
|
||||
### 9.2 Traces Explorer
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `traces.runQuery` | Update filters and run the trace query | `filters: TagFilterItem[]` |
|
||||
| `traces.addFilter` | Append a single filter | `key, op, value` |
|
||||
| `traces.changeView` | Switch between list / trace / timeseries / table | `view: string` |
|
||||
| `traces.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
|
||||
|
||||
### 9.3 Dashboards List
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `dashboards.create` | Create a new blank dashboard | `title: string, description?: string` |
|
||||
| `dashboards.search` | Filter the dashboard list | `query: string` |
|
||||
| `dashboards.duplicate` | Duplicate an existing dashboard | `dashboardId: string, newTitle?: string` |
|
||||
| `dashboards.delete` | Delete a dashboard (requires confirmation) | `dashboardId: string` |
|
||||
|
||||
### 9.4 Dashboard Detail
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `dashboard.createPanel` | Add a new panel to the current dashboard | `title: string, queryType: 'logs'\|'metrics'\|'traces'` |
|
||||
| `dashboard.rename` | Rename the current dashboard | `title: string` |
|
||||
| `dashboard.deletePanel` | Remove a panel | `panelId: string` |
|
||||
| `dashboard.addVariable` | Add a dashboard-level variable | `name: string, type: string, defaultValue?: string` |
|
||||
|
||||
### 9.5 Alerts
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `alerts.navigateCreate` | Navigate to the Create Alert page | `alertType?: 'metrics'\|'logs'\|'traces'` |
|
||||
| `alerts.disable` | Disable an existing alert rule | `alertId: string` |
|
||||
| `alerts.enable` | Enable an existing alert rule | `alertId: string` |
|
||||
| `alerts.delete` | Delete an alert rule | `alertId: string` |
|
||||
|
||||
### 9.6 Create Alert
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `alert.setCondition` | Set the alert threshold condition | `op: string, threshold: number` |
|
||||
| `alert.test` | Test the alert rule against live data | — |
|
||||
| `alert.save` | Save the alert rule | `name: string, severity?: string` |
|
||||
|
||||
### 9.7 Metrics Explorer
|
||||
|
||||
| Action ID | Description | Key Parameters |
|
||||
|-----------|-------------|----------------|
|
||||
| `metrics.runQuery` | Run a metric query | `metric: string, aggregation?: string` |
|
||||
| `metrics.saveView` | Save current query as a view | `name: string` |
|
||||
| `metrics.exportToDashboard` | Add to a dashboard | `dashboardId?: string, panelTitle?: string` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Context Block Schema (Wire Format)
|
||||
|
||||
The `[PAGE_CONTEXT]` block is a freeform text section prepended to the API message content. For the real backend, this should migrate to a structured `system` role message or a dedicated field in the request body. For the initial implementation, embedding it in the user message content is sufficient and works with any LLM API.
|
||||
|
||||
```
|
||||
[PAGE_CONTEXT]
|
||||
page: <pageId>
|
||||
actions:
|
||||
- id: <actionId>
|
||||
description: "<description>"
|
||||
params: <JSON Schema properties summary>
|
||||
...
|
||||
state:
|
||||
<actionId>: <JSON context snapshot>
|
||||
...
|
||||
[/PAGE_CONTEXT]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Parameter Validation
|
||||
|
||||
Before `execute()` is called, parameters are validated client-side using the JSON Schema stored on the `PageAction`. This catches cases where the AI generates structurally wrong parameters.
|
||||
|
||||
```typescript
|
||||
function validateParams(schema: JSONSchemaObject, params: unknown): string | null {
|
||||
// Minimal validation: check required fields are present and types match
|
||||
// Full implementation can use ajv or a lightweight equivalent
|
||||
for (const key of schema.required ?? []) {
|
||||
if (!(params as Record<string, unknown>)[key]) {
|
||||
return `Missing required parameter: "${key}"`;
|
||||
}
|
||||
}
|
||||
return null; // valid
|
||||
}
|
||||
```
|
||||
|
||||
If validation fails, `ActionBlock` shows the error inline and does not call `execute`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Answered State Persistence
|
||||
|
||||
`ActionBlock` follows the same pattern as `ConfirmBlock` and `InteractiveQuestion`:
|
||||
|
||||
- Uses `MessageContext` to read `messageId`
|
||||
- Reads `answeredBlocks[messageId]` from the Zustand store
|
||||
- On Accept: calls `markBlockAnswered(messageId, 'applied')`
|
||||
- On Dismiss: calls `markBlockAnswered(messageId, 'dismissed')`
|
||||
- On Error: calls `markBlockAnswered(messageId, 'error:<message>')`
|
||||
|
||||
This ensures re-renders and re-mounts do not reset the block to its initial state.
|
||||
|
||||
---
|
||||
|
||||
## 13. Error Handling
|
||||
|
||||
| Failure scenario | Behaviour |
|
||||
|-----------------|-----------|
|
||||
| `actionId` not in registry (page navigated away) | Block shows: "This action is no longer available — navigate back to \<page\> and try again." |
|
||||
| Parameter validation fails | Block shows the validation error inline; does not call `execute` |
|
||||
| `execute()` throws | Block shows the error message; offers a Retry button |
|
||||
| AI emits malformed JSON in the block | `RichCodeBlock` falls back to rendering the raw fenced block as a code block |
|
||||
| User navigates away mid-execution | `execute()` promise resolves/rejects normally; result is stored in `answeredBlocks` |
|
||||
|
||||
---
|
||||
|
||||
## 14. Permissions
|
||||
|
||||
Many page actions map to protected operations (e.g., creating a dashboard, deleting an alert). Each action factory should check the relevant permission before registering — if the user doesn't have permission, the action is simply not registered and will not appear in the context block.
|
||||
|
||||
```typescript
|
||||
const canCreateDashboard = useComponentPermission(['create_new_dashboards']);
|
||||
|
||||
const actions = useMemo(() => [
|
||||
...(canCreateDashboard ? [dashboardCreateAction({ ... })] : []),
|
||||
// ...
|
||||
], [canCreateDashboard, ...]);
|
||||
|
||||
usePageActions('dashboard-list', actions);
|
||||
```
|
||||
|
||||
This way the AI never suggests actions the user cannot perform.
|
||||
|
||||
---
|
||||
|
||||
## 15. Implementation Plan
|
||||
|
||||
### Phase 1 — Infrastructure (no page integrations yet)
|
||||
|
||||
1. `src/container/AIAssistant/pageActions/types.ts`
|
||||
2. `src/container/AIAssistant/pageActions/PageActionRegistry.ts`
|
||||
3. `src/container/AIAssistant/pageActions/usePageActions.ts`
|
||||
4. `ActionBlock.tsx` + `ActionBlock.scss`
|
||||
5. Register `'action'` in `blocks/index.ts`
|
||||
6. Context injection in `useAIAssistantStore.sendMessage`
|
||||
7. Mock API support for `[PAGE_CONTEXT]` → responds with `ai-action` block
|
||||
|
||||
### Phase 2 — Logs Explorer integration
|
||||
|
||||
8. `src/pages/LogsExplorer/aiActions.ts` (factories for `logs.*` actions)
|
||||
9. Wire `usePageActions` into `LogsExplorer/index.tsx`
|
||||
|
||||
### Phase 3 — Traces, Dashboards, Alerts
|
||||
|
||||
10. `src/pages/TracesExplorer/aiActions.ts`
|
||||
11. `src/pages/DashboardsListPage/aiActions.ts`
|
||||
12. `src/pages/DashboardPage/aiActions.ts`
|
||||
13. `src/pages/AlertList/aiActions.ts`
|
||||
14. `src/pages/CreateAlert/aiActions.ts`
|
||||
|
||||
### Phase 4 — Backend handoff
|
||||
|
||||
15. Move `[PAGE_CONTEXT]` from content-embedded text to a dedicated `pageContext` field in the API request body
|
||||
16. Replace mock responses with real AI backend calls
|
||||
|
||||
---
|
||||
|
||||
## 16. Open Questions
|
||||
|
||||
| # | Question | Impact |
|
||||
|---|----------|--------|
|
||||
| 1 | Should `ActionBlock` require a single user confirmation, or show a diff-style preview of what will change? | UX complexity |
|
||||
| 2 | How should multi-step actions work? (e.g. "create dashboard then add three panels") — queue them or chain them? | Architecture |
|
||||
| 3 | Should the registry support a global `getContext()` for page-agnostic state (user, org, time range)? | Context completeness |
|
||||
| 4 | What is the max context block size before it degrades AI quality? | Prompt engineering |
|
||||
| 5 | Should failed actions add a retry message back into the conversation, or stay silent? | UX |
|
||||
| 6 | Can two pages be active simultaneously (e.g. drawer open over dashboard)? How do we prioritise which actions are "active"? | Edge case |
|
||||
|
||||
---
|
||||
|
||||
## 17. Relation to Existing AI Architecture
|
||||
|
||||
```
|
||||
BlockRegistry PageActionRegistry
|
||||
│ │
|
||||
│ render blocks │ register/unregister actions
|
||||
│ (ai-chart, ai- │ (logs.runQuery, dashboard.create...)
|
||||
│ question, ai- │
|
||||
│ confirm, ...) │
|
||||
└──────────┬────────────┘
|
||||
│
|
||||
MessageBubble / StreamingMessage
|
||||
│
|
||||
RichCodeBlock (routes to BlockRegistry)
|
||||
│
|
||||
ActionBlock ←── new: reads PageActionRegistry to execute
|
||||
```
|
||||
|
||||
The `PageActionRegistry` is a parallel singleton to `BlockRegistry`. `BlockRegistry` maps `fenced-block-type → render component`. `PageActionRegistry` maps `action-id → execute function`. `ActionBlock` bridges the two: it is a registered *block* (render side) that calls into the *action* registry (execution side).
|
||||
911
frontend/src/container/AIAssistant/TECHNICAL_DESIGN.md
Normal file
911
frontend/src/container/AIAssistant/TECHNICAL_DESIGN.md
Normal file
@@ -0,0 +1,911 @@
|
||||
# AI Assistant — Technical Design Document
|
||||
|
||||
**Status:** In Progress
|
||||
**Last Updated:** 2026-04-01
|
||||
**Scope:** Frontend AI Assistant subsystem — UI, state management, API integration, page action system
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#1-overview)
|
||||
2. [Architecture Diagram](#2-architecture-diagram)
|
||||
3. [User Flows](#3-user-flows)
|
||||
4. [UI Modes and Transitions](#4-ui-modes-and-transitions)
|
||||
5. [Control Flow: UI → API → UI](#5-control-flow-ui--api--ui)
|
||||
6. [SSE Response Handling](#6-sse-response-handling)
|
||||
7. [Page Action System](#7-page-action-system)
|
||||
8. [Block Rendering System](#8-block-rendering-system)
|
||||
9. [State Management](#9-state-management)
|
||||
10. [Page-Specific Actions](#10-page-specific-actions)
|
||||
11. [Voice Input](#11-voice-input)
|
||||
12. [File Attachments](#12-file-attachments)
|
||||
13. [Development Mode (Mock)](#13-development-mode-mock)
|
||||
14. [Adding a New Page's Actions](#14-adding-a-new-pages-actions)
|
||||
15. [Adding a New Block Type](#15-adding-a-new-block-type)
|
||||
16. [Data Contracts](#16-data-contracts)
|
||||
17. [Key Design Decisions](#17-key-design-decisions)
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The AI Assistant is an embedded chat interface inside SigNoz that understands the current page context and can execute actions on behalf of the user (e.g., filter logs, update queries, navigate views). It communicates with a backend AI service via Server-Sent Events (SSE) and renders structured responses as rich interactive blocks alongside plain markdown.
|
||||
|
||||
**Key goals:**
|
||||
- **Context-aware:** the AI always knows what page the user is on and what actions are available
|
||||
- **Streaming:** responses appear token-by-token, no waiting for a full response
|
||||
- **Actionable:** the AI can trigger page mutations (filter logs, switch views) without copy-paste
|
||||
- **Extensible:** new pages can register actions; new block types can be added independently
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ User │
|
||||
└────────────────────────────┬────────────────────────────────────────┘
|
||||
│ types text / voice / file
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ UI Layer │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────────────┐ │
|
||||
│ │ Panel │ │ Modal │ │ Full-Screen Page │ │
|
||||
│ │ (drawer) │ │ (Cmd+P) │ │ /ai-assistant/:id │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └─────────────┬─────────────┘ │
|
||||
│ └────────────────┴─────────────────────────┘ │
|
||||
│ │ all modes share │
|
||||
│ ┌──────▼──────┐ │
|
||||
│ │ConversationView│ │
|
||||
│ │ + ChatInput │ │
|
||||
│ └──────┬──────┘ │
|
||||
└───────────────────────────┼─────────────────────────────────────────┘
|
||||
│ sendMessage()
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Zustand Store (useAIAssistantStore) │
|
||||
│ │
|
||||
│ conversations{} isStreaming │
|
||||
│ activeConversationId streamingContent │
|
||||
│ isDrawerOpen answeredBlocks{} │
|
||||
│ isModalOpen │
|
||||
│ │
|
||||
│ sendMessage() │
|
||||
│ 1. push user message │
|
||||
│ 2. buildContextPrefix() ──► PageActionRegistry.snapshot() │
|
||||
│ 3. call streamChat(payload) [or mockStreamChat in dev] │
|
||||
│ 4. accumulate chunks into streamingContent │
|
||||
│ 5. on done: push assistant message with actions[] │
|
||||
└──────────────────────────┬──────────────────────────────────────────┘
|
||||
│ POST /api/v1/assistant/threads
|
||||
│ (SSE response)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ API Layer (src/api/ai/chat.ts) │
|
||||
│ │
|
||||
│ streamChat(payload) → AsyncGenerator<SSEEvent> │
|
||||
│ Parses data: {...}\n\n SSE frames │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Page Action System │
|
||||
│ │
|
||||
│ PageActionRegistry ◄──── usePageActions() hook │
|
||||
│ (module singleton) (called by each page on mount) │
|
||||
│ │
|
||||
│ Registry is read by buildContextPrefix() before every API call. │
|
||||
│ │
|
||||
│ AI response → ai-action block → ActionBlock component │
|
||||
│ → PageActionRegistry.get(actionId).execute(params) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. User Flows
|
||||
|
||||
### 3.1 Basic Chat
|
||||
|
||||
```
|
||||
User opens panel (header icon / Cmd+P / trigger button)
|
||||
→ Conversation created (or resumed from store)
|
||||
→ ChatInput focused automatically
|
||||
|
||||
User types message → presses Enter
|
||||
→ User message appended to conversation
|
||||
→ StreamingMessage (typing indicator) appears
|
||||
→ SSE stream opens: tokens arrive word-by-word
|
||||
→ StreamingMessage renders live content
|
||||
→ Stream ends: StreamingMessage replaced by MessageBubble
|
||||
→ Follow-up actions (if any) shown as chips on the message
|
||||
```
|
||||
|
||||
### 3.2 AI Applies a Page Action (autoApply)
|
||||
|
||||
```
|
||||
User: "Filter logs for errors from payment-svc"
|
||||
→ PAGE_CONTEXT injected into wire payload
|
||||
(includes registered action schemas + current query state)
|
||||
→ AI responds with plain text + ai-action block
|
||||
→ ActionBlock mounts with autoApply=true
|
||||
→ execute() fires immediately on mount — no user confirmation
|
||||
→ Logs Explorer query updated via redirectWithQueryBuilderData()
|
||||
→ URL reflects new filters, QueryBuilderV2 UI updates
|
||||
→ ActionBlock shows "Applied ✓" state (persisted in answeredBlocks)
|
||||
```
|
||||
|
||||
### 3.3 AI Asks a Clarifying Question
|
||||
|
||||
```
|
||||
AI responds with ai-question block
|
||||
→ InteractiveQuestion renders (radio or checkbox)
|
||||
→ User selects answer → sendMessage() called automatically
|
||||
→ Answer persisted in answeredBlocks (survives re-renders / mode switches)
|
||||
→ Block shows answered state on re-mount
|
||||
```
|
||||
|
||||
### 3.4 AI Requests Confirmation
|
||||
|
||||
```
|
||||
AI responds with ai-confirm block
|
||||
→ ConfirmBlock renders Accept / Reject buttons
|
||||
→ Accept → sendMessage(acceptText)
|
||||
→ Reject → sendMessage(rejectText)
|
||||
→ Block shows answered state, buttons disabled
|
||||
```
|
||||
|
||||
### 3.5 Modal → Panel Minimize
|
||||
|
||||
```
|
||||
User opens modal (Cmd+P), interacts with AI
|
||||
User clicks minimize button (−)
|
||||
→ minimizeModal(): isModalOpen=false, isDrawerOpen=true (atomic)
|
||||
→ Same conversation continues in the side panel
|
||||
→ No data loss, streaming state preserved
|
||||
```
|
||||
|
||||
### 3.6 Panel → Full Screen Expand
|
||||
|
||||
```
|
||||
User clicks Maximize in panel header
|
||||
→ closeDrawer() called
|
||||
→ navigate to /ai-assistant/:conversationId
|
||||
→ Full-screen page renders same conversation
|
||||
→ TopNav (timepicker header) hidden on this route
|
||||
→ SideNav remains visible
|
||||
```
|
||||
|
||||
### 3.7 Voice Input
|
||||
|
||||
```
|
||||
User clicks mic button in ChatInput
|
||||
→ SpeechRecognition.start()
|
||||
→ isListening=true (mic turns red, CSS pulse animation)
|
||||
→ Interim results: textarea updates live as user speaks
|
||||
→ Recognition ends (auto pause detection or manual click)
|
||||
→ Final transcript committed to committedTextRef
|
||||
→ User reviews / edits text, then sends normally
|
||||
```
|
||||
|
||||
### 3.8 Resize Panel
|
||||
|
||||
```
|
||||
User hovers over left edge of panel
|
||||
→ Resize handle highlights (purple line)
|
||||
User drags left/right
|
||||
→ panel width updates live (min 380px, max 800px)
|
||||
→ document.body.cursor = 'col-resize' during drag
|
||||
→ text selection disabled during drag
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. UI Modes and Transitions
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ All Closed │
|
||||
│ (trigger shown) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
Click trigger Cmd+P navigate to
|
||||
│ │ /ai-assistant/:id
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌─────────────┐
|
||||
│ Panel │ │ Modal │ │ Full-Screen │
|
||||
│ (drawer) │ │ (portal) │ │ Page │
|
||||
└────┬─────┘ └────┬─────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
┌────▼──────────────▼───────────────▼────┐
|
||||
│ ConversationView (shared component) │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Transitions:
|
||||
Panel → Full-Screen : Maximize → closeDrawer() + history.push()
|
||||
Modal → Panel : Minimize → minimizeModal()
|
||||
Modal → Full-Screen : Maximize → closeModal() + history.push()
|
||||
Any → Closed : X button or Escape key
|
||||
|
||||
Visibility rules:
|
||||
Header AI icon : hidden when isDrawerOpen=true
|
||||
Trigger button : hidden when isDrawerOpen || isModalOpen || isFullScreenPage
|
||||
TopNav (timepicker): hidden when pathname.startsWith('/ai-assistant/')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Control Flow: UI → API → UI
|
||||
|
||||
### 5.1 Message Send
|
||||
|
||||
```
|
||||
ChatInput.handleSend()
|
||||
├── setText('') // clear textarea
|
||||
├── committedTextRef.current = '' // clear voice accumulator
|
||||
└── store.sendMessage(text, attachments)
|
||||
│
|
||||
├── Push userMessage to conversations[id].messages
|
||||
├── set isStreaming=true, streamingContent=''
|
||||
│
|
||||
├── buildContextPrefix()
|
||||
│ └── PageActionRegistry.snapshot()
|
||||
│ → returns PageActionDescriptor[] (ids, schemas, current context)
|
||||
│ → serialized as [PAGE_CONTEXT]...[/PAGE_CONTEXT] string
|
||||
│
|
||||
├── Build wire payload:
|
||||
│ {
|
||||
│ conversationId,
|
||||
│ messages: history.map((m, i) => ({
|
||||
│ role: m.role,
|
||||
│ content: i === last && role==='user'
|
||||
│ ? contextPrefix + m.content // wire only, never stored
|
||||
│ : m.content
|
||||
│ }))
|
||||
│ }
|
||||
│
|
||||
├── for await (event of streamChat(payload)):
|
||||
│ ├── streamingContent += event.content // triggers StreamingMessage re-render
|
||||
│ └── if event.done: finalActions = event.actions; break
|
||||
│
|
||||
├── Push assistantMessage { id: serverMessageId, content, actions }
|
||||
└── set isStreaming=false, streamingContent=''
|
||||
```
|
||||
|
||||
### 5.2 Streaming Render Pipeline
|
||||
|
||||
```
|
||||
streamingContent (Zustand state)
|
||||
→ StreamingMessage component (rendered while isStreaming=true)
|
||||
→ react-markdown
|
||||
→ RichCodeBlock (custom code renderer)
|
||||
→ BlockRegistry.get(lang) → renders chart / table / action / etc.
|
||||
|
||||
On stream end:
|
||||
streamingContent → assistantMessage.content (frozen in store)
|
||||
StreamingMessage removed, MessageBubble added with same content
|
||||
MessageBubble renders through identical markdown pipeline
|
||||
```
|
||||
|
||||
### 5.3 PAGE_CONTEXT Wire Format
|
||||
|
||||
The context prefix is prepended to the last user message in the API payload **only**. It is never stored in the conversation or shown in the UI.
|
||||
|
||||
```
|
||||
[PAGE_CONTEXT]
|
||||
actions:
|
||||
- id: logs.runQuery
|
||||
description: "Replace all log filters and re-run the query"
|
||||
params: {"filters": {"type": "array", "items": {...}}}
|
||||
- id: logs.addFilter
|
||||
description: "Append a single key/op/value filter"
|
||||
params: {"key": {...}, "op": {...}, "value": {...}}
|
||||
state:
|
||||
logs.runQuery: {"currentFilters": [...], "currentView": "list"}
|
||||
[/PAGE_CONTEXT]
|
||||
User's actual message text here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. SSE Response Handling
|
||||
|
||||
### 6.1 Wire Format
|
||||
|
||||
**Request:**
|
||||
```
|
||||
POST /api/v1/assistant/threads
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"conversationId": "uuid",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "[PAGE_CONTEXT]...[/PAGE_CONTEXT]\nUser text" },
|
||||
{ "role": "assistant", "content": "Previous assistant turn" },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response (SSE stream):**
|
||||
```
|
||||
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"I'll ","done":false,"actions":[]}\n\n
|
||||
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"update ","done":false,"actions":[]}\n\n
|
||||
data: {"type":"message","messageId":"srv-123","role":"assistant","content":"the query.","done":true,"actions":[
|
||||
{"id":"act-1","label":"Add another filter","kind":"follow_up","payload":{},"expiresAt":null}
|
||||
]}\n\n
|
||||
```
|
||||
|
||||
### 6.2 SSE Parsing (src/api/ai/chat.ts)
|
||||
|
||||
```
|
||||
fetch() → ReadableStream → TextDecoder → string chunks
|
||||
→ lineBuffer accumulates across chunks (handles partial lines)
|
||||
→ split on '\n\n' (SSE event boundary)
|
||||
→ for each complete part:
|
||||
find line starting with 'data: '
|
||||
strip prefix → parse JSON → yield SSEEvent
|
||||
→ '[DONE]' sentinel → stop iteration
|
||||
→ malformed JSON → skip silently
|
||||
→ finally: reader.releaseLock()
|
||||
```
|
||||
|
||||
### 6.3 Structured Content in the Stream
|
||||
|
||||
The AI embeds block payloads as markdown fenced code blocks with `ai-*` language tags inside the `content` stream. These are parsed live as tokens arrive:
|
||||
|
||||
````markdown
|
||||
Here are the results:
|
||||
|
||||
```ai-graph
|
||||
{
|
||||
"title": "p99 Latency",
|
||||
"datasets": [...]
|
||||
}
|
||||
```
|
||||
|
||||
The spike started at 14:45.
|
||||
````
|
||||
|
||||
React-markdown renders the code block → `RichCodeBlock` detects the `ai-` prefix → looks up `BlockRegistry` → renders the chart/table/action component.
|
||||
|
||||
### 6.4 actions[] Array
|
||||
|
||||
Actions arrive on the **final** SSE event (`done: true`). They are stored on the `Message` object. Each action's `kind` determines UI behavior:
|
||||
|
||||
| Kind | Behavior |
|
||||
|---|---|
|
||||
| `follow_up` | Rendered as suggestion chip; click sends as new message |
|
||||
| `open_resource` | Opens a SigNoz resource (trace, log, dashboard) |
|
||||
| `navigate` | Navigates to a SigNoz route |
|
||||
| `apply_filter` | Directly triggers a registered page action |
|
||||
| `open_docs` | Opens a documentation URL |
|
||||
| `undo` | Reverts the last page mutation |
|
||||
| `revert` | Reverts to a specified previous state |
|
||||
|
||||
---
|
||||
|
||||
## 7. Page Action System
|
||||
|
||||
### 7.1 Concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---|---|
|
||||
| `PageAction<TParams>` | An action a page exposes to the AI: id, description, JSON Schema params, `execute()`, optional `getContext()`, optional `autoApply` |
|
||||
| `PageActionRegistry` | Module-level singleton (`Map<pageId, actions[]>` + `Map<actionId, action>`) |
|
||||
| `usePageActions(pageId, actions)` | React hook — registers on mount, unregisters on unmount |
|
||||
| `PageActionDescriptor` | Serializable version of `PageAction` (no functions) — sent to AI via PAGE_CONTEXT |
|
||||
| `AIActionBlock` | Shape the AI emits when invoking an action: `{ actionId, description, parameters }` |
|
||||
|
||||
### 7.2 Lifecycle
|
||||
|
||||
```
|
||||
Page component mounts
|
||||
└── usePageActions('logs-explorer', actions)
|
||||
└── PageActionRegistry.register('logs-explorer', actions)
|
||||
→ added to _byPage map (for bulk unregister)
|
||||
→ added to _byId map (for O(1) lookup by actionId)
|
||||
|
||||
User sends any message
|
||||
└── buildContextPrefix()
|
||||
└── PageActionRegistry.snapshot()
|
||||
→ returns PageActionDescriptor[] with current context values
|
||||
|
||||
AI response contains ```ai-action block
|
||||
└── ActionBlock component mounts
|
||||
├── PageActionRegistry.get(actionId) → PageAction with execute()
|
||||
└── if autoApply: execute(params) on mount
|
||||
else: render confirmation card, execute on user click
|
||||
|
||||
Page component unmounts
|
||||
└── usePageActions cleanup
|
||||
└── PageActionRegistry.unregister('logs-explorer')
|
||||
→ all actions for this page removed from both maps
|
||||
```
|
||||
|
||||
### 7.3 ActionBlock State Machine
|
||||
|
||||
**autoApply: true** (fires immediately on mount):
|
||||
```
|
||||
mount
|
||||
→ hasFired ref guard (prevents double-fire in React StrictMode)
|
||||
→ PageActionRegistry.get(actionId).execute(params)
|
||||
→ render: loading spinner
|
||||
→ success: "Applied ✓" state, markBlockAnswered(messageId, 'applied')
|
||||
→ error: error state with message, markBlockAnswered(messageId, 'error:...')
|
||||
```
|
||||
|
||||
**autoApply: false** (user must confirm):
|
||||
```
|
||||
mount
|
||||
→ render: description + parameter summary + Apply / Dismiss buttons
|
||||
→ Apply clicked:
|
||||
→ execute(params) → loading → applied state
|
||||
→ markBlockAnswered(messageId, 'applied')
|
||||
→ Dismiss clicked:
|
||||
→ markBlockAnswered(messageId, 'dismissed')
|
||||
```
|
||||
|
||||
**Re-mount (scroll / mode switch):**
|
||||
```
|
||||
mount
|
||||
→ answeredBlocks[messageId] exists
|
||||
→ render answered state directly (skip pending UI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Block Rendering System
|
||||
|
||||
### 8.1 Registration
|
||||
|
||||
`src/container/AIAssistant/components/blocks/index.ts` registers all built-in types at import time (side-effect import):
|
||||
|
||||
```typescript
|
||||
BlockRegistry.register('action', ActionBlock);
|
||||
BlockRegistry.register('question', InteractiveQuestion);
|
||||
BlockRegistry.register('confirm', ConfirmBlock);
|
||||
BlockRegistry.register('timeseries', TimeseriesBlock);
|
||||
BlockRegistry.register('barchart', BarChartBlock);
|
||||
BlockRegistry.register('piechart', PieChartBlock);
|
||||
BlockRegistry.register('linechart', LineChartBlock);
|
||||
BlockRegistry.register('graph', LineChartBlock); // alias
|
||||
```
|
||||
|
||||
### 8.2 Render Pipeline
|
||||
|
||||
```
|
||||
MessageBubble (assistant message)
|
||||
└── react-markdown
|
||||
└── components={{ code: RichCodeBlock }}
|
||||
└── RichCodeBlock
|
||||
├── lang.startsWith('ai-') ?
|
||||
│ yes → BlockRegistry.get(lang.slice(3))
|
||||
│ → parse JSON content
|
||||
│ → render block component
|
||||
└── no → render plain <code> element
|
||||
```
|
||||
|
||||
### 8.3 Block Component Interface
|
||||
|
||||
All block components receive:
|
||||
```typescript
|
||||
interface BlockProps {
|
||||
content: string; // raw JSON string from the fenced code block body
|
||||
}
|
||||
```
|
||||
|
||||
Blocks access shared context via:
|
||||
```typescript
|
||||
const { messageId } = useContext(MessageContext); // for answeredBlocks key
|
||||
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore(s => s.sendMessage); // for interactive blocks
|
||||
```
|
||||
|
||||
### 8.4 Block Types Reference
|
||||
|
||||
| Tag | Component | Purpose |
|
||||
|---|---|---|
|
||||
| `ai-action` | `ActionBlock` | Invokes a registered page action |
|
||||
| `ai-question` | `InteractiveQuestion` | Radio or checkbox user selection |
|
||||
| `ai-confirm` | `ConfirmBlock` | Binary accept / reject prompt |
|
||||
| `ai-timeseries` | `TimeseriesBlock` | Tabular data with rows and columns |
|
||||
| `ai-barchart` | `BarChartBlock` | Horizontal / vertical bar chart |
|
||||
| `ai-piechart` | `PieChartBlock` | Doughnut / pie chart |
|
||||
| `ai-linechart` | `LineChartBlock` | Multi-series line chart |
|
||||
| `ai-graph` | `LineChartBlock` | Alias for `ai-linechart` |
|
||||
|
||||
---
|
||||
|
||||
## 9. State Management
|
||||
|
||||
### 9.1 Store Shape (Zustand + Immer)
|
||||
|
||||
```typescript
|
||||
interface AIAssistantStore {
|
||||
// UI
|
||||
isDrawerOpen: boolean;
|
||||
isModalOpen: boolean;
|
||||
activeConversationId: string | null;
|
||||
|
||||
// Data
|
||||
conversations: Record<string, Conversation>;
|
||||
|
||||
// Streaming
|
||||
streamingContent: string; // accumulates token-by-token during SSE stream
|
||||
isStreaming: boolean;
|
||||
|
||||
// Block answer persistence
|
||||
answeredBlocks: Record<string, string>; // messageId → answer string
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Conversation Structure
|
||||
|
||||
```typescript
|
||||
interface Conversation {
|
||||
id: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
title?: string; // auto-derived from first user message (60 char max)
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string; // server messageId for assistant turns, uuidv4 for user
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
attachments?: MessageAttachment[];
|
||||
actions?: AssistantAction[]; // follow-up actions, present on final assistant message only
|
||||
createdAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Streaming State Machine
|
||||
|
||||
```
|
||||
idle
|
||||
→ sendMessage() called
|
||||
→ isStreaming=true, streamingContent=''
|
||||
|
||||
streaming
|
||||
→ each SSE chunk: streamingContent += event.content (triggers StreamingMessage re-render)
|
||||
→ done event: isStreaming=false, streamingContent=''
|
||||
→ assistant message pushed to conversation
|
||||
|
||||
idle (settled)
|
||||
→ MessageBubble renders final frozen content
|
||||
→ ChatInput re-enabled (disabled={isStreaming})
|
||||
```
|
||||
|
||||
### 9.4 Answered Block Persistence
|
||||
|
||||
Interactive blocks call `markBlockAnswered(messageId, answer)` on completion. On re-mount, blocks check `answeredBlocks[messageId]` and render the answered state directly. This ensures:
|
||||
- Scrolling away and back does not reset blocks
|
||||
- Switching UI modes (panel → full-screen) does not reset blocks
|
||||
- Blocks cannot be answered twice
|
||||
|
||||
---
|
||||
|
||||
## 10. Page-Specific Actions
|
||||
|
||||
### 10.1 Logs Explorer
|
||||
|
||||
**File:** `src/pages/LogsExplorer/aiActions.ts`
|
||||
**Registered in:** `src/pages/LogsExplorer/index.tsx` via `usePageActions('logs-explorer', aiActions)`
|
||||
|
||||
| Action ID | autoApply | Description |
|
||||
|---|---|---|
|
||||
| `logs.runQuery` | `true` | Replace all filters in the query builder and re-run |
|
||||
| `logs.addFilter` | `true` | Append a single `key / op / value` filter |
|
||||
| `logs.changeView` | `true` | Switch between list / timeseries / table views |
|
||||
| `logs.saveView` | `false` | Save current query as a named view (requires confirmation) |
|
||||
|
||||
**Critical implementation detail:** All query mutations use `redirectWithQueryBuilderData()`, not `handleSetQueryData`. The Logs Explorer's `QueryBuilderV2` is URL-driven — `compositeQuery` in the URL is the source of truth for displayed filters. `handleSetQueryData` updates React state only; `redirectWithQueryBuilderData` syncs the URL, making changes visible in the UI.
|
||||
|
||||
**Context shape provided to AI:**
|
||||
```typescript
|
||||
getContext: () => ({
|
||||
currentFilters: currentQuery.builder.queryData[0].filters.items,
|
||||
currentView: currentView, // 'list' | 'timeseries' | 'table'
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Voice Input
|
||||
|
||||
### 11.1 Hook: useSpeechRecognition
|
||||
|
||||
**File:** `src/container/AIAssistant/hooks/useSpeechRecognition.ts`
|
||||
|
||||
```typescript
|
||||
const { isListening, isSupported, start, stop, transcript, isFinal } =
|
||||
useSpeechRecognition({ lang: 'en-US', onError });
|
||||
```
|
||||
|
||||
Exposes `transcript` and `isFinal` as React state (not callbacks) so `ChatInput` reacts via `useEffect([transcript, isFinal])`, eliminating stale closure issues.
|
||||
|
||||
### 11.2 Interim vs Final Handling
|
||||
|
||||
```
|
||||
onresult (isFinal=false) → pendingInterim = text → setTranscript(text), setIsFinal(false)
|
||||
onresult (isFinal=true) → pendingInterim = '' → setTranscript(text), setIsFinal(true)
|
||||
onend (pendingInterim) → setTranscript(pendingInterim), setIsFinal(true)
|
||||
↑ fallback: Chrome often skips the final onresult when stop() is called manually
|
||||
```
|
||||
|
||||
### 11.3 Text Accumulation in ChatInput
|
||||
|
||||
```
|
||||
committedTextRef.current = '' // tracks finalized text (typed + confirmed speech)
|
||||
|
||||
isFinal=false (interim):
|
||||
setText(committedTextRef.current + ' ' + transcript)
|
||||
// textarea shows live speech; committedTextRef unchanged
|
||||
|
||||
isFinal=true (final):
|
||||
committedTextRef.current += ' ' + transcript
|
||||
setText(committedTextRef.current)
|
||||
// both textarea and ref updated — text is now "committed"
|
||||
|
||||
User types manually:
|
||||
setText(e.target.value)
|
||||
committedTextRef.current = e.target.value
|
||||
// keeps both in sync so next speech session appends correctly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. File Attachments
|
||||
|
||||
`ChatInput` uses Ant Design `Upload` with `beforeUpload` returning `false` (prevents auto-upload). Files accumulate in `pendingFiles: UploadFile[]` state. On send, files are converted to data URIs (`fileToDataUrl`) and stored on the `Message` as `attachments[]`.
|
||||
|
||||
**Accepted types:** `image/*`, `.pdf`, `.txt`, `.log`, `.csv`, `.json`
|
||||
|
||||
**Rendered in MessageBubble:**
|
||||
- Images → inline `<img>` preview
|
||||
- Other files → file badge chip (name + type)
|
||||
|
||||
---
|
||||
|
||||
## 13. Development Mode (Mock)
|
||||
|
||||
Set `VITE_AI_MOCK=true` in `.env.local` to use the mock API instead of the real SSE endpoint.
|
||||
|
||||
```typescript
|
||||
// store/useAIAssistantStore.ts
|
||||
const USE_MOCK_AI = import.meta.env.VITE_AI_MOCK === 'true';
|
||||
const chat = USE_MOCK_AI ? mockStreamChat : streamChat;
|
||||
```
|
||||
|
||||
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. It selects canned responses from keyword matching on the last user message and simulates word-by-word streaming with 15–45ms random delays.
|
||||
|
||||
**Trigger keywords:**
|
||||
|
||||
| Keyword(s) | Response type |
|
||||
|---|---|
|
||||
| `filter logs`, `payment` + `error` | `ai-action`: logs.runQuery |
|
||||
| `add filter` | `ai-action`: logs.addFilter |
|
||||
| `change view` / `timeseries view` | `ai-action`: logs.changeView |
|
||||
| `save view` | `ai-action`: logs.saveView |
|
||||
| `error` / `exception` | Error rates table + trace snippet |
|
||||
| `latency` / `p99` / `graph` | Line chart (p99 latency) |
|
||||
| `bar` / `top service` | Bar chart (error count) |
|
||||
| `pie` / `distribution` | Pie / doughnut chart |
|
||||
| `timeseries` / `table` | Timeseries data table |
|
||||
| `log` | Top log errors summary |
|
||||
| `confirm` / `alert` / `anomal` | `ai-confirm` block |
|
||||
| `environment` / `question` | `ai-question` (radio) |
|
||||
| `level` / `select` / `filter` | `ai-question` (checkbox) |
|
||||
|
||||
---
|
||||
|
||||
## 14. Adding a New Page's Actions
|
||||
|
||||
### Step 1 — Create an aiActions file
|
||||
|
||||
```typescript
|
||||
// src/pages/TracesExplorer/aiActions.ts
|
||||
import { PageAction } from 'container/AIAssistant/pageActions/types';
|
||||
|
||||
interface FilterTracesParams {
|
||||
service: string;
|
||||
minDurationMs?: number;
|
||||
}
|
||||
|
||||
export function tracesFilterAction(deps: {
|
||||
currentQuery: Query;
|
||||
redirectWithQueryBuilderData: (q: Query) => void;
|
||||
}): PageAction<FilterTracesParams> {
|
||||
return {
|
||||
id: 'traces.filter', // globally unique: pageName.actionName
|
||||
description: 'Filter traces by service name and minimum duration',
|
||||
autoApply: true,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
service: { type: 'string', description: 'Service name to filter by' },
|
||||
minDurationMs: { type: 'number', description: 'Minimum span duration in ms' },
|
||||
},
|
||||
required: ['service'],
|
||||
},
|
||||
execute: async ({ service, minDurationMs }) => {
|
||||
// Build updated query and redirect
|
||||
deps.redirectWithQueryBuilderData(buildUpdatedQuery(service, minDurationMs));
|
||||
return { summary: `Filtered traces for ${service}` };
|
||||
},
|
||||
getContext: () => ({
|
||||
currentFilters: deps.currentQuery.builder.queryData[0].filters.items,
|
||||
}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Register in the page component
|
||||
|
||||
```typescript
|
||||
// src/pages/TracesExplorer/index.tsx
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import { tracesFilterAction } from './aiActions';
|
||||
|
||||
function TracesExplorer() {
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
const aiActions = useMemo(() => [
|
||||
tracesFilterAction({ currentQuery, redirectWithQueryBuilderData }),
|
||||
], [currentQuery, redirectWithQueryBuilderData]);
|
||||
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
|
||||
// ... rest of component
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- `pageId` must be unique across pages (kebab-case convention)
|
||||
- `actionId` must be globally unique — use `pageName.actionName` convention
|
||||
- `actions` array **must be memoized** (`useMemo`) — identity change triggers re-registration
|
||||
- For URL-driven state (QueryBuilder), always use the URL-sync API; never use `handleSetQueryData` alone
|
||||
- `getContext()` should return only what the AI needs to make decisions — keep it minimal
|
||||
|
||||
---
|
||||
|
||||
## 15. Adding a New Block Type
|
||||
|
||||
### Step 1 — Create the component
|
||||
|
||||
```typescript
|
||||
// src/container/AIAssistant/components/blocks/MyBlock.tsx
|
||||
import { useContext } from 'react';
|
||||
import MessageContext from '../MessageContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
interface MyBlockPayload {
|
||||
title: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
export default function MyBlock({ content }: { content: string }): JSX.Element {
|
||||
const payload = JSON.parse(content) as MyBlockPayload;
|
||||
const { messageId } = useContext(MessageContext);
|
||||
const markBlockAnswered = useAIAssistantStore(s => s.markBlockAnswered);
|
||||
const answered = useAIAssistantStore(s => s.answeredBlocks[messageId]);
|
||||
|
||||
if (answered) return <div className="ai-block--answered">Done</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4>{payload.title}</h4>
|
||||
{/* ... */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — Register in index.ts
|
||||
|
||||
```typescript
|
||||
// src/container/AIAssistant/components/blocks/index.ts
|
||||
import MyBlock from './MyBlock';
|
||||
BlockRegistry.register('myblock', MyBlock);
|
||||
```
|
||||
|
||||
### Step 3 — AI emits the block tag
|
||||
|
||||
````markdown
|
||||
```ai-myblock
|
||||
{
|
||||
"title": "Something",
|
||||
"items": ["a", "b"]
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
## 16. Data Contracts
|
||||
|
||||
### 16.1 API Request
|
||||
|
||||
```typescript
|
||||
POST /api/v1/assistant/threads
|
||||
{
|
||||
conversationId: string,
|
||||
messages: Array<{
|
||||
role: 'user' | 'assistant',
|
||||
content: string // last user message includes [PAGE_CONTEXT]...[/PAGE_CONTEXT] prefix
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
### 16.2 SSE Event Schema
|
||||
|
||||
```typescript
|
||||
interface SSEEvent {
|
||||
type: 'message';
|
||||
messageId: string; // server-assigned; consistent across all chunks of one turn
|
||||
role: 'assistant';
|
||||
content: string; // incremental chunk — NOT cumulative
|
||||
done: boolean; // true on the last event of a turn
|
||||
actions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
kind: 'follow_up' | 'open_resource' | 'navigate' | 'apply_filter' | 'open_docs' | 'undo' | 'revert';
|
||||
payload: Record<string, unknown>;
|
||||
expiresAt: string | null; // ISO-8601 or null
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### 16.3 ai-action Block Payload (embedded in content stream)
|
||||
|
||||
```typescript
|
||||
{
|
||||
actionId: string, // must match a registered PageAction.id
|
||||
description: string, // shown in the confirmation card (autoApply=false)
|
||||
parameters: Record<string, unknown> // must conform to the action's JSON Schema
|
||||
}
|
||||
```
|
||||
|
||||
### 16.4 PageAction Interface
|
||||
|
||||
```typescript
|
||||
interface PageAction<TParams = Record<string, any>> {
|
||||
id: string;
|
||||
description: string;
|
||||
parameters: JSONSchemaObject;
|
||||
execute: (params: TParams) => Promise<{ summary: string; data?: unknown }>;
|
||||
getContext?: () => unknown; // called on every sendMessage() to populate PAGE_CONTEXT
|
||||
autoApply?: boolean; // default false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. Key Design Decisions
|
||||
|
||||
### Context injection is wire-only
|
||||
PAGE_CONTEXT is injected into the wire payload but never stored or shown in the UI. This keeps conversations readable, avoids polluting history with system context, and ensures the AI always gets fresh page state on every message rather than stale state from when the conversation started.
|
||||
|
||||
### URL-driven query builders require URL-sync APIs
|
||||
Pages that use URL-driven state (e.g., `QueryBuilderV2` with `compositeQuery` URL param) **must** use the URL-sync API (`redirectWithQueryBuilderData`) when actions mutate query state. Using React `setState` alone does not update the URL, so the displayed filters do not change. This was the root cause of the first major bug in the Logs Explorer integration.
|
||||
|
||||
### autoApply co-located with action definition
|
||||
The `autoApply` flag lives on the `PageAction` definition, not in the UI layer. The page that owns the action knows whether it is safe to apply without confirmation. Additive / reversible actions use `autoApply: true`. Actions that create persistent artifacts (saved views, alert rules) use `autoApply: false`.
|
||||
|
||||
### Transcript-as-state for voice input
|
||||
`useSpeechRecognition` exposes `transcript` and `isFinal` as React state rather than using an `onTranscript` callback. The callback approach had a race condition: recognition events could fire before the `useEffect` that wired up the callback had run, leaving `onTranscriptRef.current` unset. State-based approach uses normal React reactivity with no timing dependency.
|
||||
|
||||
### Block answer persistence across re-mounts
|
||||
Interactive blocks persist their answered state to `answeredBlocks[messageId]` in the Zustand store. Without this, switching UI modes or scrolling away and back would reset blocks to their unanswered state, allowing the user to re-submit answers and send duplicate messages to the AI.
|
||||
|
||||
### Panel resize is not persisted
|
||||
Panel width resets to 380px on close/reopen. If persistence is needed, save `panelWidth` to `localStorage` in the drag `onMouseUp` handler and initialize `useState` from it.
|
||||
|
||||
### Mock API shares the same interface
|
||||
`mockStreamChat` implements the same `AsyncGenerator<SSEEvent>` interface as `streamChat`. The store switches between them via `VITE_AI_MOCK=true`. This means the mock exercises the exact same store code path as production — no separate code branch to maintain.
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* AIAssistantIcon — SigNoz AI Assistant icon (V2 — Minimal Line).
|
||||
*
|
||||
* Single-weight stroke outline of a bear face. Inherits `currentColor` so it
|
||||
* adapts to any dark/light context automatically. The only hard-coded color is
|
||||
* the SigNoz red (#E8432D) eye bar — one bold accent, nothing else.
|
||||
*/
|
||||
|
||||
interface AIAssistantIconProps {
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AIAssistantIcon({
|
||||
size = 24,
|
||||
className,
|
||||
}: AIAssistantIconProps): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-label="AI Assistant"
|
||||
role="img"
|
||||
>
|
||||
{/* Ears */}
|
||||
<path
|
||||
d="M8 13.5 C8 8 5 6 5 11 C5 14 7 15.5 9.5 15.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M24 13.5 C24 8 27 6 27 11 C27 14 25 15.5 22.5 15.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Head */}
|
||||
<rect
|
||||
x="7"
|
||||
y="12"
|
||||
width="18"
|
||||
height="15"
|
||||
rx="7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
|
||||
{/* Eye bar — SigNoz red, the only accent */}
|
||||
<line
|
||||
x1="10"
|
||||
y1="18"
|
||||
x2="22"
|
||||
y2="18"
|
||||
stroke="#E8432D"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="12" cy="18" r="1" fill="#E8432D" />
|
||||
<circle cx="20" cy="18" r="1" fill="#E8432D" />
|
||||
|
||||
{/* Nose */}
|
||||
<ellipse
|
||||
cx="16"
|
||||
cy="23.5"
|
||||
rx="1.6"
|
||||
ry="1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
120
frontend/src/container/AIAssistant/components/ApprovalCard.tsx
Normal file
120
frontend/src/container/AIAssistant/components/ApprovalCard.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Check, Shield, X } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { PendingApproval } from '../types';
|
||||
|
||||
interface ApprovalCardProps {
|
||||
conversationId: string;
|
||||
approval: PendingApproval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered when the agent emits an `approval` SSE event.
|
||||
* The agent has paused execution; the user must approve or reject
|
||||
* before the stream resumes on a new execution.
|
||||
*/
|
||||
export default function ApprovalCard({
|
||||
conversationId,
|
||||
approval,
|
||||
}: ApprovalCardProps): JSX.Element {
|
||||
const approveAction = useAIAssistantStore((s) => s.approveAction);
|
||||
const rejectAction = useAIAssistantStore((s) => s.rejectAction);
|
||||
const isStreaming = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
|
||||
const [decided, setDecided] = useState<'approved' | 'rejected' | null>(null);
|
||||
|
||||
const handleApprove = async (): Promise<void> => {
|
||||
setDecided('approved');
|
||||
await approveAction(conversationId, approval.approvalId);
|
||||
};
|
||||
|
||||
const handleReject = async (): Promise<void> => {
|
||||
setDecided('rejected');
|
||||
await rejectAction(conversationId, approval.approvalId);
|
||||
};
|
||||
|
||||
// After decision the card shows a compact confirmation row
|
||||
if (decided === 'approved') {
|
||||
return (
|
||||
<div className="ai-approval ai-approval--decided">
|
||||
<Check
|
||||
size={13}
|
||||
className="ai-approval__status-icon ai-approval__status-icon--ok"
|
||||
/>
|
||||
<span className="ai-approval__status-text">Approved — resuming…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (decided === 'rejected') {
|
||||
return (
|
||||
<div className="ai-approval ai-approval--decided">
|
||||
<X
|
||||
size={13}
|
||||
className="ai-approval__status-icon ai-approval__status-icon--no"
|
||||
/>
|
||||
<span className="ai-approval__status-text">Rejected.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-approval">
|
||||
<div className="ai-approval__header">
|
||||
<Shield size={13} className="ai-approval__shield-icon" />
|
||||
<span className="ai-approval__header-label">Action requires approval</span>
|
||||
<span className="ai-approval__resource-badge">
|
||||
{approval.actionType} · {approval.resourceType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="ai-approval__summary">{approval.summary}</p>
|
||||
|
||||
{approval.diff && (
|
||||
<div className="ai-approval__diff">
|
||||
{approval.diff.before !== undefined && (
|
||||
<div className="ai-approval__diff-block ai-approval__diff-block--before">
|
||||
<span className="ai-approval__diff-label">Before</span>
|
||||
<pre className="ai-approval__diff-json">
|
||||
{JSON.stringify(approval.diff.before, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{approval.diff.after !== undefined && (
|
||||
<div className="ai-approval__diff-block ai-approval__diff-block--after">
|
||||
<span className="ai-approval__diff-label">After</span>
|
||||
<pre className="ai-approval__diff-json">
|
||||
{JSON.stringify(approval.diff.after, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ai-approval__actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
onClick={handleApprove}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
<Check size={12} />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={handleReject}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
<X size={12} />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
frontend/src/container/AIAssistant/components/ChatInput.tsx
Normal file
256
frontend/src/container/AIAssistant/components/ChatInput.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import type { UploadFile } from 'antd';
|
||||
import { Upload } from 'antd';
|
||||
import { Mic, Paperclip, Send, Square, X } from 'lucide-react';
|
||||
|
||||
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
|
||||
import { MessageAttachment } from '../types';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, attachments?: MessageAttachment[]) => void;
|
||||
onCancel?: () => void;
|
||||
disabled?: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (): void => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
onSend,
|
||||
onCancel,
|
||||
disabled,
|
||||
isStreaming = false,
|
||||
}: ChatInputProps): JSX.Element {
|
||||
const [text, setText] = useState('');
|
||||
const [pendingFiles, setPendingFiles] = useState<UploadFile[]>([]);
|
||||
// Stores the already-committed final text so interim results don't overwrite it
|
||||
const committedTextRef = useRef('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Focus the textarea when this component mounts (panel/modal open)
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed && pendingFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments: MessageAttachment[] = await Promise.all(
|
||||
pendingFiles.map(async (f) => {
|
||||
const dataUrl = f.originFileObj ? await fileToDataUrl(f.originFileObj) : '';
|
||||
return {
|
||||
name: f.name,
|
||||
type: f.type ?? 'application/octet-stream',
|
||||
dataUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
onSend(trimmed, attachments.length > 0 ? attachments : undefined);
|
||||
setText('');
|
||||
committedTextRef.current = '';
|
||||
setPendingFiles([]);
|
||||
textareaRef.current?.focus();
|
||||
}, [text, pendingFiles, onSend]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const removeFile = useCallback((uid: string) => {
|
||||
setPendingFiles((prev) => prev.filter((f) => f.uid !== uid));
|
||||
}, []);
|
||||
|
||||
// ── Voice input ────────────────────────────────────────────────────────────
|
||||
|
||||
const { isListening, isSupported, start, discard } = useSpeechRecognition({
|
||||
onTranscript: (transcriptText, isFinal) => {
|
||||
if (isFinal) {
|
||||
// Commit: append to whatever the user has already typed
|
||||
const separator = committedTextRef.current ? ' ' : '';
|
||||
const next = committedTextRef.current + separator + transcriptText;
|
||||
committedTextRef.current = next;
|
||||
setText(next);
|
||||
} else {
|
||||
// Interim: live preview appended to committed text, not yet persisted
|
||||
const separator = committedTextRef.current ? ' ' : '';
|
||||
setText(committedTextRef.current + separator + transcriptText);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Stop recording and immediately send whatever is in the textarea.
|
||||
const handleStopAndSend = useCallback(async () => {
|
||||
// Promote the displayed text (interim included) to committed so handleSend sees it.
|
||||
committedTextRef.current = text;
|
||||
// Stop recognition without triggering onTranscript again (would double-append).
|
||||
discard();
|
||||
await handleSend();
|
||||
}, [text, discard, handleSend]);
|
||||
|
||||
// Stop recording and revert the textarea to what it was before voice started.
|
||||
const handleDiscard = useCallback(() => {
|
||||
discard();
|
||||
setText(committedTextRef.current);
|
||||
textareaRef.current?.focus();
|
||||
}, [discard]);
|
||||
|
||||
return (
|
||||
<div className="ai-assistant-input">
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="ai-assistant-input__attachments">
|
||||
{pendingFiles.map((f) => (
|
||||
<div key={f.uid} className="ai-assistant-input__attachment-chip">
|
||||
<span className="ai-assistant-input__attachment-name">{f.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ai-assistant-input__attachment-remove"
|
||||
onClick={(): void => removeFile(f.uid)}
|
||||
aria-label={`Remove ${f.name}`}
|
||||
>
|
||||
<X size={11} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ai-assistant-input__row">
|
||||
<Upload
|
||||
multiple
|
||||
accept="image/*,.pdf,.txt,.log,.csv,.json"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file): boolean => {
|
||||
setPendingFiles((prev) => [
|
||||
...prev,
|
||||
{
|
||||
uid: file.uid,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
originFileObj: file,
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label="Attach file"
|
||||
>
|
||||
<Paperclip size={14} />
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="ai-assistant-input__textarea"
|
||||
placeholder="Ask anything… (Shift+Enter for new line)"
|
||||
value={text}
|
||||
onChange={(e): void => {
|
||||
setText(e.target.value);
|
||||
// Keep committed text in sync when the user edits manually
|
||||
committedTextRef.current = e.target.value;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
{isListening ? (
|
||||
<div className="ai-mic-recording">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-mic-recording__discard"
|
||||
onClick={handleDiscard}
|
||||
aria-label="Discard recording"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
<span className="ai-mic-recording__waves" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ai-mic-recording__stop"
|
||||
onClick={handleStopAndSend}
|
||||
aria-label="Stop and send"
|
||||
>
|
||||
<Square size={9} fill="currentColor" strokeWidth={0} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
!isSupported
|
||||
? 'Voice input not supported in this browser'
|
||||
: 'Voice input'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={start}
|
||||
disabled={disabled || !isSupported}
|
||||
aria-label="Start voice input"
|
||||
className="ai-mic-btn"
|
||||
>
|
||||
<Mic size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isStreaming && onCancel ? (
|
||||
<Tooltip title="Stop generating">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="icon"
|
||||
className="ai-assistant-input__send-btn ai-assistant-input__send-btn--stop"
|
||||
onClick={onCancel}
|
||||
aria-label="Stop generating"
|
||||
>
|
||||
<Square size={10} fill="currentColor" strokeWidth={0} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="icon"
|
||||
className="ai-assistant-input__send-btn"
|
||||
onClick={isListening ? handleStopAndSend : handleSend}
|
||||
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send size={14} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { HelpCircle, Send } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { ClarificationField, PendingClarification } from '../types';
|
||||
|
||||
interface ClarificationFormProps {
|
||||
conversationId: string;
|
||||
clarification: PendingClarification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered when the agent emits a `clarification` SSE event.
|
||||
* Dynamically renders form fields based on the `fields` array and
|
||||
* submits answers to resume the agent on a new execution.
|
||||
*/
|
||||
export default function ClarificationForm({
|
||||
conversationId,
|
||||
clarification,
|
||||
}: ClarificationFormProps): JSX.Element {
|
||||
const submitClarification = useAIAssistantStore((s) => s.submitClarification);
|
||||
const isStreaming = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.isStreaming ?? false,
|
||||
);
|
||||
|
||||
const initialAnswers = Object.fromEntries(
|
||||
clarification.fields.map((f) => [f.id, f.default ?? '']),
|
||||
);
|
||||
const [answers, setAnswers] = useState<Record<string, unknown>>(
|
||||
initialAnswers,
|
||||
);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const setField = (id: string, value: unknown): void => {
|
||||
setAnswers((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
setSubmitted(true);
|
||||
await submitClarification(
|
||||
conversationId,
|
||||
clarification.clarificationId,
|
||||
answers,
|
||||
);
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="ai-clarification ai-clarification--submitted">
|
||||
<Send size={13} className="ai-clarification__icon" />
|
||||
<span className="ai-clarification__status-text">
|
||||
Answers submitted — resuming…
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-clarification">
|
||||
<div className="ai-clarification__header">
|
||||
<HelpCircle size={13} className="ai-clarification__header-icon" />
|
||||
<span className="ai-clarification__header-label">A few details needed</span>
|
||||
</div>
|
||||
|
||||
<p className="ai-clarification__message">{clarification.message}</p>
|
||||
|
||||
<div className="ai-clarification__fields">
|
||||
{clarification.fields.map((field) => (
|
||||
<FieldInput
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={answers[field.id]}
|
||||
onChange={(val): void => setField(field.id, val)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ai-clarification__actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
<Send size={12} />
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Field renderer — handles text, number, select, radio, checkbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FieldInputProps {
|
||||
field: ClarificationField;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
|
||||
const { id, type, label, required, options } = field;
|
||||
|
||||
if (type === 'select' && options) {
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<label className="ai-clarification__label" htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</label>
|
||||
<select
|
||||
id={id}
|
||||
className="ai-clarification__select"
|
||||
value={String(value ?? '')}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'radio' && options) {
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<span className="ai-clarification__label">
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</span>
|
||||
<div className="ai-clarification__radio-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="ai-clarification__radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name={id}
|
||||
value={opt}
|
||||
checked={value === opt}
|
||||
onChange={(): void => onChange(opt)}
|
||||
className="ai-clarification__radio"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'checkbox' && options) {
|
||||
const selected = Array.isArray(value) ? (value as string[]) : [];
|
||||
const toggle = (opt: string): void => {
|
||||
onChange(
|
||||
selected.includes(opt)
|
||||
? selected.filter((v) => v !== opt)
|
||||
: [...selected, opt],
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<span className="ai-clarification__label">
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</span>
|
||||
<div className="ai-clarification__checkbox-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="ai-clarification__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt)}
|
||||
onChange={(): void => toggle(opt)}
|
||||
className="ai-clarification__checkbox"
|
||||
/>
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text / number (default)
|
||||
return (
|
||||
<div className="ai-clarification__field">
|
||||
<label className="ai-clarification__label" htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className="ai-clarification__required">*</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type === 'number' ? 'number' : 'text'}
|
||||
className="ai-clarification__input"
|
||||
value={String(value ?? '')}
|
||||
onChange={(e): void =>
|
||||
onChange(type === 'number' ? Number(e.target.value) : e.target.value)
|
||||
}
|
||||
placeholder={label}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import { MessageSquare, Pencil, Trash2 } from 'lucide-react';
|
||||
|
||||
import { Conversation } from '../types';
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: Conversation;
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
function formatRelativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) {
|
||||
return 'just now';
|
||||
}
|
||||
if (mins < 60) {
|
||||
return `${mins}m ago`;
|
||||
}
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) {
|
||||
return `${hrs}h ago`;
|
||||
}
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) {
|
||||
return `${days}d ago`;
|
||||
}
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ConversationItem({
|
||||
conversation,
|
||||
isActive,
|
||||
onSelect,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: ConversationItemProps): JSX.Element {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const displayTitle = conversation.title ?? 'New conversation';
|
||||
const ts = conversation.updatedAt ?? conversation.createdAt;
|
||||
|
||||
const startEditing = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditValue(conversation.title ?? '');
|
||||
setIsEditing(true);
|
||||
},
|
||||
[conversation.title],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
onRename(conversation.id, editValue);
|
||||
setIsEditing(false);
|
||||
}, [conversation.id, editValue, onRename]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
commitEdit();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
}
|
||||
},
|
||||
[commitEdit],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete(conversation.id);
|
||||
},
|
||||
[conversation.id, onDelete],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ai-history__item${isActive ? ' ai-history__item--active' : ''}`}
|
||||
onClick={(): void => onSelect(conversation.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onSelect(conversation.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={12} className="ai-history__item-icon" />
|
||||
|
||||
<div className="ai-history__item-body">
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="ai-history__item-input"
|
||||
value={editValue}
|
||||
onChange={(e): void => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={commitEdit}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
maxLength={80}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="ai-history__item-title" title={displayTitle}>
|
||||
{displayTitle}
|
||||
</span>
|
||||
<span className="ai-history__item-time">{formatRelativeTime(ts)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="ai-history__item-actions">
|
||||
<Tooltip title="Rename">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ai-history__item-btn"
|
||||
onClick={startEditing}
|
||||
aria-label="Rename conversation"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ai-history__item-btn ai-history__item-btn--danger"
|
||||
onClick={handleDelete}
|
||||
aria-label="Delete conversation"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/container/AIAssistant/components/HistorySidebar.tsx
Normal file
146
frontend/src/container/AIAssistant/components/HistorySidebar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Loader2, Plus } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { Conversation } from '../types';
|
||||
import ConversationItem from './ConversationItem';
|
||||
|
||||
interface HistorySidebarProps {
|
||||
/** Called when a conversation is selected — lets the parent navigate if needed */
|
||||
onSelect?: (id: string) => void;
|
||||
}
|
||||
|
||||
function groupByDate(
|
||||
conversations: Conversation[],
|
||||
): { label: string; items: Conversation[] }[] {
|
||||
const now = Date.now();
|
||||
const DAY = 86_400_000;
|
||||
|
||||
const groups: Record<string, Conversation[]> = {
|
||||
Today: [],
|
||||
Yesterday: [],
|
||||
'Last 7 days': [],
|
||||
'Last 30 days': [],
|
||||
Older: [],
|
||||
};
|
||||
|
||||
for (const conv of conversations) {
|
||||
const age = now - (conv.updatedAt ?? conv.createdAt);
|
||||
if (age < DAY) {
|
||||
groups.Today.push(conv);
|
||||
} else if (age < 2 * DAY) {
|
||||
groups.Yesterday.push(conv);
|
||||
} else if (age < 7 * DAY) {
|
||||
groups['Last 7 days'].push(conv);
|
||||
} else if (age < 30 * DAY) {
|
||||
groups['Last 30 days'].push(conv);
|
||||
} else {
|
||||
groups.Older.push(conv);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(groups)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
export default function HistorySidebar({
|
||||
onSelect,
|
||||
}: HistorySidebarProps): JSX.Element {
|
||||
const conversations = useAIAssistantStore((s) => s.conversations);
|
||||
const activeConversationId = useAIAssistantStore(
|
||||
(s) => s.activeConversationId,
|
||||
);
|
||||
const isLoadingThreads = useAIAssistantStore((s) => s.isLoadingThreads);
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
(s) => s.startNewConversation,
|
||||
);
|
||||
const setActiveConversation = useAIAssistantStore(
|
||||
(s) => s.setActiveConversation,
|
||||
);
|
||||
const loadThread = useAIAssistantStore((s) => s.loadThread);
|
||||
const fetchThreads = useAIAssistantStore((s) => s.fetchThreads);
|
||||
const deleteConversation = useAIAssistantStore((s) => s.deleteConversation);
|
||||
const renameConversation = useAIAssistantStore((s) => s.renameConversation);
|
||||
|
||||
// Fetch thread history from backend on mount
|
||||
useEffect(() => {
|
||||
fetchThreads();
|
||||
}, [fetchThreads]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
Object.values(conversations).sort(
|
||||
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
|
||||
),
|
||||
[conversations],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => groupByDate(sorted), [sorted]);
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
const conv = conversations[id];
|
||||
if (conv?.threadId) {
|
||||
// Always load from backend — refreshes messages and reconnects
|
||||
// to active execution if the thread is still busy.
|
||||
loadThread(conv.threadId);
|
||||
} else {
|
||||
// Local-only conversation (no backend thread yet)
|
||||
setActiveConversation(id);
|
||||
}
|
||||
onSelect?.(id);
|
||||
};
|
||||
|
||||
const handleNew = (): void => {
|
||||
const id = startNewConversation();
|
||||
onSelect?.(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-history">
|
||||
<div className="ai-history__header">
|
||||
<span className="ai-history__heading">History</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
className="ai-history__new-btn"
|
||||
>
|
||||
<Plus size={13} />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ai-history__list">
|
||||
{isLoadingThreads && groups.length === 0 && (
|
||||
<div className="ai-history__loading">
|
||||
<Loader2 size={16} className="ai-history__spinner" />
|
||||
Loading conversations…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingThreads && groups.length === 0 && (
|
||||
<p className="ai-history__empty">No conversations yet.</p>
|
||||
)}
|
||||
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className="ai-history__group">
|
||||
<span className="ai-history__group-label">{label}</span>
|
||||
{items.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onSelect={handleSelect}
|
||||
onRename={renameConversation}
|
||||
onDelete={deleteConversation}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/container/AIAssistant/components/MessageBubble.tsx
Normal file
141
frontend/src/container/AIAssistant/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
// Side-effect: registers all built-in block types into the BlockRegistry
|
||||
import './blocks';
|
||||
|
||||
import { Message, MessageBlock } from '../types';
|
||||
import { RichCodeBlock } from './blocks';
|
||||
import { MessageContext } from './MessageContext';
|
||||
import MessageFeedback from './MessageFeedback';
|
||||
import ThinkingStep from './ThinkingStep';
|
||||
import ToolCallStep from './ToolCallStep';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* react-markdown renders fenced code blocks as <pre><code>...</code></pre>.
|
||||
* When RichCodeBlock replaces <code> with a custom AI block component, the
|
||||
* block ends up wrapped in <pre> which forces monospace font and white-space:pre.
|
||||
* This renderer detects that case and unwraps the <pre>.
|
||||
*/
|
||||
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const childArr = React.Children.toArray(children);
|
||||
if (childArr.length === 1) {
|
||||
const child = childArr[0];
|
||||
// If the code component returned something other than a <code> element
|
||||
// (i.e. a custom AI block), render without the <pre> wrapper.
|
||||
if (React.isValidElement(child) && child.type !== 'code') {
|
||||
return <>{child}</>;
|
||||
}
|
||||
}
|
||||
return <pre>{children}</pre>;
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
/** Renders a single MessageBlock by type. */
|
||||
function renderBlock(block: MessageBlock, index: number): JSX.Element {
|
||||
switch (block.type) {
|
||||
case 'thinking':
|
||||
return <ThinkingStep key={index} content={block.content} />;
|
||||
case 'tool_call':
|
||||
// Blocks in a persisted message are always complete — done is always true.
|
||||
return (
|
||||
<ToolCallStep
|
||||
key={index}
|
||||
toolCall={{
|
||||
toolName: block.toolName,
|
||||
input: block.toolInput,
|
||||
result: block.result,
|
||||
done: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={index}
|
||||
className="ai-message__markdown"
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{block.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function MessageBubble({
|
||||
message,
|
||||
onRegenerate,
|
||||
isLastAssistant = false,
|
||||
}: MessageBubbleProps): JSX.Element {
|
||||
const isUser = message.role === 'user';
|
||||
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ai-message ai-message--${isUser ? 'user' : 'assistant'}`}
|
||||
data-testid={`ai-message-${message.id}`}
|
||||
>
|
||||
<div className="ai-message__body">
|
||||
<div className="ai-message__bubble">
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="ai-message__attachments">
|
||||
{message.attachments.map((att) => {
|
||||
const isImage = att.type.startsWith('image/');
|
||||
return isImage ? (
|
||||
<img
|
||||
key={att.name}
|
||||
src={att.dataUrl}
|
||||
alt={att.name}
|
||||
className="ai-message__attachment-image"
|
||||
/>
|
||||
) : (
|
||||
<div key={att.name} className="ai-message__attachment-file">
|
||||
{att.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUser ? (
|
||||
<p className="ai-message__text">{message.content}</p>
|
||||
) : hasBlocks ? (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
{/* eslint-disable-next-line react/no-array-index-key */}
|
||||
{message.blocks!.map((block, i) => renderBlock(block, i))}
|
||||
</MessageContext.Provider>
|
||||
) : (
|
||||
<MessageContext.Provider value={{ messageId: message.id }}>
|
||||
<ReactMarkdown
|
||||
className="ai-message__markdown"
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
</MessageContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isUser && (
|
||||
<MessageFeedback
|
||||
message={message}
|
||||
onRegenerate={onRegenerate}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface MessageContextValue {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export const MessageContext = createContext<MessageContextValue>({
|
||||
messageId: '',
|
||||
});
|
||||
|
||||
export const useMessageContext = (): MessageContextValue =>
|
||||
useContext(MessageContext);
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Tooltip } from '@signozhq/ui';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { FeedbackRating, Message } from '../types';
|
||||
|
||||
interface MessageFeedbackProps {
|
||||
message: Message;
|
||||
onRegenerate?: () => void;
|
||||
isLastAssistant?: boolean;
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const diffMs = Date.now() - timestamp;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSec < 10) {
|
||||
return 'just now';
|
||||
}
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec}s ago`;
|
||||
}
|
||||
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) {
|
||||
return `${diffMin} min${diffMin === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) {
|
||||
return `${diffHr} hr${diffHr === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
export default function MessageFeedback({
|
||||
message,
|
||||
onRegenerate,
|
||||
isLastAssistant = false,
|
||||
}: MessageFeedbackProps): JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const submitMessageFeedback = useAIAssistantStore(
|
||||
(s) => s.submitMessageFeedback,
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
// Local vote state — initialised from persisted feedbackRating, updated
|
||||
// immediately on click so the UI responds without waiting for the API.
|
||||
const [vote, setVote] = useState<FeedbackRating | null>(
|
||||
message.feedbackRating ?? null,
|
||||
);
|
||||
|
||||
const [relativeTime, setRelativeTime] = useState(() =>
|
||||
formatRelativeTime(message.createdAt),
|
||||
);
|
||||
|
||||
const absoluteTime = useMemo(
|
||||
() =>
|
||||
formatTimezoneAdjustedTimestamp(
|
||||
message.createdAt,
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
),
|
||||
[message.createdAt, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
|
||||
// Tick relative time every 30 s
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setRelativeTime(formatRelativeTime(message.createdAt));
|
||||
}, 30_000);
|
||||
return (): void => clearInterval(id);
|
||||
}, [message.createdAt]);
|
||||
|
||||
const handleCopy = useCallback((): void => {
|
||||
copyToClipboard(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [copyToClipboard, message.content]);
|
||||
|
||||
const handleVote = useCallback(
|
||||
(rating: FeedbackRating): void => {
|
||||
if (vote === rating) {
|
||||
return;
|
||||
}
|
||||
setVote(rating);
|
||||
submitMessageFeedback(message.id, rating);
|
||||
},
|
||||
[vote, message.id, submitMessageFeedback],
|
||||
);
|
||||
|
||||
const feedbackClass = `ai-message-feedback${
|
||||
isLastAssistant ? ' ai-message-feedback--visible' : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={feedbackClass}>
|
||||
<div className="ai-message-feedback__actions">
|
||||
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
|
||||
<Button
|
||||
className={`ai-message-feedback__btn${
|
||||
copied ? ' ai-message-feedback__btn--active' : ''
|
||||
}`}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Good response">
|
||||
<Button
|
||||
className={`ai-message-feedback__btn${
|
||||
vote === 'positive' ? ' ai-message-feedback__btn--voted-up' : ''
|
||||
}`}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(): void => handleVote('positive')}
|
||||
>
|
||||
<ThumbsUp size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Bad response">
|
||||
<Button
|
||||
className={`ai-message-feedback__btn${
|
||||
vote === 'negative' ? ' ai-message-feedback__btn--voted-down' : ''
|
||||
}`}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={(): void => handleVote('negative')}
|
||||
>
|
||||
<ThumbsDown size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{onRegenerate && (
|
||||
<Tooltip title="Regenerate">
|
||||
<Button
|
||||
className="ai-message-feedback__btn"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="ai-message-feedback__time">
|
||||
{relativeTime} · {absoluteTime}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user