diff --git a/frontend/package.json b/frontend/package.json index 104155d7e0..cfc09f89cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,7 +51,7 @@ "@signozhq/design-tokens": "2.1.4", "@signozhq/icons": "0.1.0", "@signozhq/resizable": "0.0.2", - "@signozhq/ui": "0.0.10", + "@signozhq/ui": "0.0.13", "@tanstack/react-table": "8.21.3", "@tanstack/react-virtual": "3.13.22", "@uiw/codemirror-theme-copilot": "4.23.11", diff --git a/frontend/src/container/AIAssistant/ConversationView.tsx b/frontend/src/container/AIAssistant/ConversationView.tsx index 1426d345c8..f995f9e186 100644 --- a/frontend/src/container/AIAssistant/ConversationView.tsx +++ b/frontend/src/container/AIAssistant/ConversationView.tsx @@ -1,9 +1,11 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; import cx from 'classnames'; -import ChatInput from './components/ChatInput'; +import ChatInput, { autoContextKey } from './components/ChatInput'; import ConversationSkeleton from './components/ConversationSkeleton'; import VirtualizedMessages from './components/VirtualizedMessages'; +import { getAutoContexts } from './getAutoContexts'; import { useAIAssistantStore } from './store/useAIAssistantStore'; import { MessageAttachment } from './types'; import { MessageContext } from '../../api/ai/chat'; @@ -20,6 +22,7 @@ export default function ConversationView({ }: ConversationViewProps): JSX.Element { const variant = useVariant(); const isCompact = variant === 'panel'; + const location = useLocation(); const conversation = useAIAssistantStore( (s) => s.conversations[conversationId], @@ -37,6 +40,42 @@ export default function ConversationView({ const sendMessage = useAIAssistantStore((s) => s.sendMessage); const cancelStream = useAIAssistantStore((s) => s.cancelStream); + // Auto-derived contexts come from the route the user is currently looking + // at (dashboard detail, service metrics, an explorer, …). Skip when the + // user is on the standalone AI Assistant page — there's no "underlying" + // page context to attach. ChatInput renders these as chips and merges + // them with the user's `@`-mention picks before invoking onSend. + const allAutoContexts = useMemo( + () => + variant === 'page' + ? [] + : getAutoContexts(location.pathname, location.search), + [variant, location.pathname, location.search], + ); + + // User-dismissed auto-context entries. Reset whenever the URL changes — + // dismissals are scoped to "this page", not the whole conversation. + const [dismissedAutoKeys, setDismissedAutoKeys] = useState>( + () => new Set(), + ); + useEffect(() => { + setDismissedAutoKeys(new Set()); + }, [location.pathname, location.search]); + + const autoContexts = useMemo( + () => + allAutoContexts.filter((ctx) => !dismissedAutoKeys.has(autoContextKey(ctx))), + [allAutoContexts, dismissedAutoKeys], + ); + + const handleDismissAutoContext = useCallback((key: string): void => { + setDismissedAutoKeys((prev) => { + const next = new Set(prev); + next.add(key); + return next; + }); + }, []); + const handleSend = useCallback( ( text: string, @@ -72,7 +111,12 @@ export default function ConversationView({
- +
); @@ -96,6 +140,8 @@ export default function ConversationView({ onCancel={handleCancel} disabled={inputDisabled} isStreaming={isStreamingHere} + autoContexts={autoContexts} + onDismissAutoContext={handleDismissAutoContext} /> diff --git a/frontend/src/container/AIAssistant/components/ApprovalCard.module.scss b/frontend/src/container/AIAssistant/components/ApprovalCard.module.scss index 39933b227e..4c024d5544 100644 --- a/frontend/src/container/AIAssistant/components/ApprovalCard.module.scss +++ b/frontend/src/container/AIAssistant/components/ApprovalCard.module.scss @@ -1,3 +1,5 @@ +@use '../_scrollbar' as *; + $radius: 4px; .card { @@ -55,9 +57,131 @@ $radius: 4px; line-height: 1.5; } +.diffSection { + display: flex; + flex-direction: column; + gap: 6px; +} + +.diffHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.diffHeaderLabel { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--l2-foreground); +} + .diff { display: flex; gap: 8px; + + // Fixed-height dialog (70vh) — let the diff fill the body and the + // JSON panes scroll internally rather than pushing the dialog taller. + &.expanded { + flex: 1; + min-height: 0; + + .diffBlock { + min-height: 0; + } + + .diffJson { + flex: 1; + max-height: none; + overflow: auto; + font-size: 12px; + } + } + + // Unified view: a single column instead of two side-by-side blocks. + // The block-level flex switches to column so the diff pane fills. + &.unified { + flex-direction: column; + } +} + +.diffHeaderActions { + display: flex; + align-items: center; + gap: 4px; +} + +// Container for line-by-line diff output. Mirrors `.diffJson` for scroll +// + monospace styling but renders an inner stack of `.diffLine` rows +// instead of a single `
` so individual lines can be colored.
+.diffPane {
+	font-family: var(--font-mono, monospace);
+	font-size: 12px;
+	background: var(--l2-background);
+	border-radius: $radius;
+	margin: 0;
+	overflow: auto;
+	color: var(--l2-foreground);
+	flex: 1;
+	min-height: 0;
+	@include scrollbar(0.4rem);
+
+	&.wrapped .diffLineText {
+		white-space: pre-wrap;
+		word-break: break-word;
+	}
+}
+
+.diffLine {
+	display: flex;
+	align-items: flex-start;
+	gap: 6px;
+	padding: 0 8px;
+	min-height: 18px;
+	line-height: 1.5;
+}
+
+.diffLineAdd {
+	background: rgba(34, 197, 94, 0.12);
+	color: var(--l1-foreground);
+
+	.diffGutter {
+		color: var(--bg-forest-500, #22c55e);
+	}
+}
+
+.diffLineRemove {
+	background: rgba(239, 68, 68, 0.12);
+	color: var(--l1-foreground);
+
+	.diffGutter {
+		color: var(--bg-cherry-500, #ef4444);
+	}
+}
+
+// Empty filler row in split view to keep before/after columns aligned
+// when one side has an added/removed line. Visible as a faint band so
+// the eye still tracks the row.
+.diffLinePlaceholder {
+	background: rgba(120, 120, 120, 0.06);
+	min-height: 18px;
+}
+
+.diffGutter {
+	flex-shrink: 0;
+	width: 12px;
+	text-align: center;
+	font-weight: 600;
+	user-select: none;
+	color: var(--l3-foreground, var(--l2-foreground));
+}
+
+.diffLineText {
+	white-space: pre;
+	flex: 1;
+	min-width: 0;
 }
 
 .diffBlock {
@@ -75,6 +199,14 @@ $radius: 4px;
 	}
 }
 
+.diffBlockHeader {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	gap: 8px;
+	min-height: 18px;
+}
+
 .diffLabel {
 	font-size: 10px;
 	font-weight: 600;
@@ -89,10 +221,45 @@ $radius: 4px;
 	border-radius: $radius;
 	padding: 5px 7px;
 	margin: 0;
-	overflow-x: auto;
+	overflow: auto;
 	white-space: pre;
 	max-height: 140px;
 	color: var(--l2-foreground);
+	@include scrollbar(0.4rem);
+
+	// Wrap long lines instead of horizontal scrolling. Used in the
+	// expanded modal when the user toggles the "Wrap text" button.
+	&.wrapped {
+		white-space: pre-wrap;
+		word-break: break-word;
+		overflow-x: hidden;
+	}
+}
+
+.diffModalBody {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+	flex: 1;
+	min-height: 0;
+	padding: 16px;
+	overflow: hidden;
+}
+
+.diffToolbarRow {
+	display: flex;
+	justify-content: flex-end;
+	align-items: center;
+	gap: 8px;
+	flex-shrink: 0;
+}
+
+.diffModalSummary {
+	font-size: 13px;
+	color: var(--l2-foreground);
+	margin: 0;
+	line-height: 1.5;
+	flex-shrink: 0;
 }
 
 .actions {
diff --git a/frontend/src/container/AIAssistant/components/ApprovalCard.tsx b/frontend/src/container/AIAssistant/components/ApprovalCard.tsx
index 5b22d2c352..954f30b141 100644
--- a/frontend/src/container/AIAssistant/components/ApprovalCard.tsx
+++ b/frontend/src/container/AIAssistant/components/ApprovalCard.tsx
@@ -1,8 +1,31 @@
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
+import { useCopyToClipboard } from 'react-use';
 import cx from 'classnames';
-import { Button } from '@signozhq/ui';
-import type { ApprovalEventDTO } from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
-import { Check, Shield, X } from '@signozhq/icons';
+import {
+	Button,
+	Dialog,
+	DialogCloseButton,
+	DialogContent,
+	DialogHeader,
+	DialogSubtitle,
+	DialogTitle,
+	ToggleGroup,
+	ToggleGroupItem,
+} from '@signozhq/ui';
+import type {
+	ApprovalEventDTO,
+	ApprovalEventDTODiffAnyOf,
+} from 'api/generated/services/ai-assistant/sigNozAIAssistantAPI.schemas';
+import {
+	Check,
+	Columns2,
+	Copy,
+	List,
+	Maximize2,
+	Shield,
+	WrapText,
+	X,
+} from '@signozhq/icons';
 
 import { useAIAssistantStore } from '../store/useAIAssistantStore';
 
@@ -29,6 +52,9 @@ export default function ApprovalCard({
 	);
 
 	const [decided, setDecided] = useState<'approved' | 'rejected' | null>(null);
+	const [diffExpanded, setDiffExpanded] = useState(false);
+	const [wrapText, setWrapText] = useState(false);
+	const [viewMode, setViewMode] = useState('split');
 
 	const handleApprove = async (): Promise => {
 		setDecided('approved');
@@ -72,26 +98,84 @@ export default function ApprovalCard({
 			

{approval.summary}

{approval.diff && ( -
- {approval.diff.before !== undefined && ( -
- Before -
-								{JSON.stringify(approval.diff.before, null, 2)}
-							
-
- )} - {approval.diff.after !== undefined && ( -
- After -
-								{JSON.stringify(approval.diff.after, null, 2)}
-							
-
- )} +
+
+ Diff + +
+
)} + + + + Approval diff + + {approval.actionType} · {approval.resourceType} + + +
+

{approval.summary}

+
+ { + // Radix `single` group can emit '' when the active item + // is clicked again — preserve the current mode. + if (next === 'split' || next === 'unified') { + setViewMode(next); + } + }} + > + + + + + + + + setWrapText(next.includes('wrap'))} + > + + + + +
+ {approval.diff && ( + + )} +
+ setDiffExpanded(false)} /> +
+
+
+ ); +} diff --git a/frontend/src/container/AIAssistant/components/ChatInput.module.scss b/frontend/src/container/AIAssistant/components/ChatInput.module.scss index 50f784e563..8d88d81ddc 100644 --- a/frontend/src/container/AIAssistant/components/ChatInput.module.scss +++ b/frontend/src/container/AIAssistant/components/ChatInput.module.scss @@ -46,6 +46,19 @@ $radius: 4px; min-height: 0 !important; width: auto !important; } + + // `auto` chips are derived from the URL (current page) — visually + // distinguished by a dashed border + slightly muted text so the user + // can tell them apart from explicit @-mentions. Tighter padding / + // font-size keeps them visually subordinate to user `@`-picks. + &.auto { + border-style: dashed; + color: var(--l2-foreground); + background: transparent; + font-size: 10px; + padding: 2px 4px 2px 6px; + gap: 3px; + } } .contextTagContent { diff --git a/frontend/src/container/AIAssistant/components/ChatInput.tsx b/frontend/src/container/AIAssistant/components/ChatInput.tsx index 5fc4f74c2d..60ddbe1325 100644 --- a/frontend/src/container/AIAssistant/components/ChatInput.tsx +++ b/frontend/src/container/AIAssistant/components/ChatInput.tsx @@ -44,6 +44,89 @@ interface ChatInputProps { onCancel?: () => void; disabled?: boolean; isStreaming?: boolean; + /** + * URL-derived `source: 'auto'` contexts representing the page the user is + * currently looking at. Rendered as chips alongside the user's `@`-mention + * picks and merged into the outgoing `contexts` array. + */ + autoContexts?: MessageContext[]; + /** + * Called when the user dismisses an auto-context chip. The parent owns + * the dismissed set and is responsible for filtering the next render's + * `autoContexts` to exclude the key. + */ + onDismissAutoContext?: (key: string) => void; +} + +/** Stable identity for an auto-context entry — used as React key + dismissal id. */ +export function autoContextKey(ctx: MessageContext): string { + const page = (ctx.metadata as { page?: string } | null | undefined)?.page; + return `auto:${ctx.type}:${ctx.resourceId ?? ''}:${page ?? ''}`; +} + +/** + * Friendly label for an auto-derived context chip. We don't fetch resource + * names from the URL alone, so we lean on the page identity that already + * lives in `metadata.page`, falling back to the resource type. + */ +function autoContextLabel(ctx: MessageContext): string { + const page = (ctx.metadata as { page?: string } | null | undefined)?.page; + switch (page) { + case 'dashboard_detail': + return 'Current dashboard'; + case 'panel_edit': + return 'Editing panel'; + case 'panel_fullscreen': + return 'Panel (fullscreen)'; + case 'dashboard_list': + return 'Dashboards'; + case 'alert_edit': + return 'Editing alert'; + case 'alert_new': + return 'New alert'; + case 'alerts_triggered': + return 'Triggered alerts'; + case 'alert_list': + return 'Alerts'; + case 'service_detail': + return 'Current service'; + case 'services_list': + return 'Services'; + case 'logs_explorer': + return 'Logs explorer'; + case 'log_detail': + return 'Log details'; + case 'traces_explorer': + return 'Traces explorer'; + case 'trace_detail': + return 'Trace details'; + case 'metrics_explorer': + return 'Metrics explorer'; + default: + return ctx.type; + } +} + +/** Capitalised category badge text — e.g. "Dashboard", "Logs explorer". */ +function autoContextCategory(ctx: MessageContext): string { + switch (ctx.type) { + case 'dashboard': + return 'Dashboard'; + case 'alert': + return 'Alert'; + case 'service': + return 'Service'; + case 'logs_explorer': + return 'Logs'; + case 'traces_explorer': + return 'Traces'; + case 'metrics_explorer': + return 'Metrics'; + case 'saved_view': + return 'Saved view'; + default: + return ctx.type; + } } const MAX_INPUT_LENGTH = 20000; @@ -129,6 +212,8 @@ export default function ChatInput({ onCancel, disabled, isStreaming = false, + autoContexts, + onDismissAutoContext, }: ChatInputProps): JSX.Element { const { selectedTime } = useSelector( (state) => state.globalTime, @@ -141,6 +226,15 @@ export default function ChatInput({ const [isContextPickerOpen, setIsContextPickerOpen] = useState(false); const [activeContextCategory, setActiveContextCategory] = useState('Dashboards'); + // When the picker was opened by typing `@` in the textarea, this holds the + // span of `@` (start / end indices into `text`). Used both for live + // filtering of the entity list and for splicing the trigger out of the + // text once the user picks an item. `null` when the picker is opened via + // the "Add Context" button (no trigger to strip, no query to filter). + const [mentionRange, setMentionRange] = useState<{ + start: number; + end: number; + } | null>(null); const [servicesTimeRange] = useState(() => { const now = Date.now(); return { @@ -164,35 +258,49 @@ export default function ChatInput({ const atIndex = beforeCaret.lastIndexOf('@'); if (atIndex < 0) { setIsContextPickerOpen(false); + setMentionRange(null); return; } const query = beforeCaret.slice(atIndex + 1); if (/\s/.test(query)) { setIsContextPickerOpen(false); + setMentionRange(null); return; } setIsContextPickerOpen(true); + setMentionRange({ start: atIndex, end: caret }); }, [], ); const toggleContextSelection = useCallback( (category: ContextCategory, entityId: string, contextValue: string) => { - setSelectedContexts((prev) => { - const alreadySelected = prev.some( - (item) => item.category === category && item.entityId === entityId, - ); + const wasSelected = selectedContexts.some( + (item) => item.category === category && item.entityId === entityId, + ); - if (alreadySelected) { + setSelectedContexts((prev) => { + if (wasSelected) { return prev.filter( (item) => !(item.category === category && item.entityId === entityId), ); } - return [...prev, { category, entityId, value: contextValue }]; }); + + // When the user picks an item via the `@` trigger, splice the + // `@` span out of the textarea so their prose stays clean. + // Skip on remove (no trigger to strip) and when the picker was + // opened from the "Add Context" button (no mention range tracked). + if (!wasSelected && mentionRange) { + const next = + text.slice(0, mentionRange.start) + text.slice(mentionRange.end); + setText(next); + committedTextRef.current = next; + setMentionRange(null); + } }, - [], + [mentionRange, selectedContexts, text], ); // Focus the textarea when this component mounts (panel/modal open) @@ -217,9 +325,12 @@ export default function ChatInput({ }), ); - const contexts = selectedContexts + const userContexts = selectedContexts .map(toMessageContext) .filter((context): context is MessageContext => context !== null); + // Auto contexts come first so the agent reads "current page" before + // any explicit @-mentions when both are present. + const contexts = [...(autoContexts ?? []), ...userContexts]; const payload = capText(trimmed); onSend( @@ -232,7 +343,7 @@ export default function ChatInput({ setPendingFiles([]); setSelectedContexts([]); textareaRef.current?.focus(); - }, [text, pendingFiles, onSend, selectedContexts, capText]); + }, [text, pendingFiles, onSend, selectedContexts, autoContexts, capText]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -303,13 +414,21 @@ export default function ChatInput({ isError: isDashboardsError, } = useGetAllDashboard(); + // Enable list fetches both for the picker (when the corresponding category + // is open) AND for auto-context chips that need to resolve a name from a + // resource id. Dashboards is unconditional since the underlying hook + // has no `enabled` flag. + const needsAlertResolve = (autoContexts ?? []).some( + (c) => c.type === 'alert' && Boolean(c.resourceId), + ); + const { data: alertsResponse, isLoading: isAlertsLoading, isError: isAlertsError, } = useListRules({ query: { - enabled: activeContextCategory === 'Alerts', + enabled: activeContextCategory === 'Alerts' || needsAlertResolve, }, }); @@ -328,6 +447,43 @@ export default function ChatInput({ }, }); + /** + * Resolves an auto-context to a human label: dashboard title, alert name, + * service name (the service `resourceId` IS the name), or a generic page + * label as fallback while the lookup data is still loading. + */ + const resolveAutoContextName = useCallback( + (ctx: MessageContext): string => { + if (ctx.type === 'service' && ctx.resourceId) { + return ctx.resourceId; + } + if (ctx.type === 'dashboard' && ctx.resourceId) { + const dash = dashboardsResponse?.data?.find((d) => d.id === ctx.resourceId); + if (dash?.data.title) { + return dash.data.title; + } + } + if (ctx.type === 'alert' && ctx.resourceId) { + const rule = alertsResponse?.data?.find((r) => r.id === ctx.resourceId); + if (rule?.alert) { + return rule.alert; + } + } + const page = ( + ctx.metadata as { page?: string; traceId?: string } | null | undefined + )?.page; + if (page === 'trace_detail') { + const traceId = (ctx.metadata as { traceId?: string } | null | undefined) + ?.traceId; + if (traceId) { + return `${traceId.slice(0, 8)}…`; + } + } + return autoContextLabel(ctx); + }, + [dashboardsResponse, alertsResponse], + ); + const contextEntitiesByCategory: Record = { Dashboards: @@ -374,8 +530,17 @@ export default function ChatInput({ }, }; - const filteredContextOptions = - contextEntitiesByCategory[activeContextCategory]; + // Type-ahead filter against the `@` typed in the textarea. When the + // picker was opened from the "Add Context" button there's no query, so we + // show every entity for the active category. + const mentionQuery = mentionRange + ? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase() + : ''; + const filteredContextOptions = mentionQuery + ? contextEntitiesByCategory[activeContextCategory].filter((entity) => + entity.value.toLowerCase().includes(mentionQuery), + ) + : contextEntitiesByCategory[activeContextCategory]; const { isLoading: isActiveContextLoading, isError: isActiveContextError } = contextCategoryStateByCategory[activeContextCategory]; const currentLength = text.length; @@ -402,8 +567,39 @@ export default function ChatInput({
)} - {selectedContexts.length > 0 && ( + {(selectedContexts.length > 0 || + (autoContexts && autoContexts.length > 0)) && (
+ {autoContexts?.map((ctx) => { + const key = autoContextKey(ctx); + const label = resolveAutoContextName(ctx); + const category = autoContextCategory(ctx); + return ( +
+
+ + {category} + + {label} +
+ {onDismissAutoContext && ( + + )} +
+ ); + })} {selectedContexts.map((contextItem) => (
{ + // `multiple` is off → callback receives a single string. The wider + // `string | string[]` typing comes from the shared Select root. + const picked = Array.isArray(next) ? (next[0] ?? '') : next; + if (picked === CUSTOM_OPTION_SENTINEL) { + setIsCustom(true); + onChange(customValue); + } else { + setIsCustom(false); + onChange(picked); + } + }; - if (type === 'select' && options) { return (
- - - {options.map((opt) => ( - - ))} - + + {/* Pin the dropdown width to the trigger via Radix's + `--radix-select-trigger-width`; otherwise the popover + sizes to its widest item and looks misaligned. */} + + {options?.map((opt) => ( + + {opt} + + ))} + {allowCustom && ( + + {CUSTOM_OPTION_LABEL} + + )} + + + {isCustom && ( + { + setCustomValue(e.target.value); + onChange(e.target.value); + }} + /> + )}
); } - if (type === 'radio' && options) { + // Same fallback logic as the select branch — render the radio group + // when there are options OR when the field is `allowCustom` only. + if (type === 'radio' && (options || allowCustom)) { + const handleRadioChange = (next: string): void => { + if (next === CUSTOM_OPTION_SENTINEL) { + setIsCustom(true); + onChange(customValue); + } else { + setIsCustom(false); + onChange(next); + } + }; + + const radioValue = isCustom + ? CUSTOM_OPTION_SENTINEL + : typeof value === 'string' + ? value + : ''; + return (
{label} {required && *} -
- {options.map((opt) => ( - + ))} -
+ {allowCustom && ( + + {CUSTOM_OPTION_LABEL} + + )} + + {isCustom && ( + { + setCustomValue(e.target.value); + onChange(e.target.value); + }} + /> + )}
); } - if (type === 'checkbox' && options) { + // Same fallback logic as the select branch — render the checkbox group + // when there are options OR when the field is `allowCustom` only. + if (type === 'checkbox' && (options || allowCustom)) { const selected = Array.isArray(value) ? (value as string[]) : []; - const toggle = (opt: string): void => { + // Anything in the value array that isn't one of the predefined options + // is treated as a custom entry — we keep at most one custom entry, + // driven by the local `customValue` + `isCustom` state below. + const regularSelected = selected.filter((v) => options?.includes(v)); + + const toggleRegular = (opt: string): void => { + const nextRegular = regularSelected.includes(opt) + ? regularSelected.filter((v) => v !== opt) + : [...regularSelected, opt]; onChange( - selected.includes(opt) - ? selected.filter((v) => v !== opt) - : [...selected, opt], + isCustom && customValue ? [...nextRegular, customValue] : nextRegular, ); }; + + const toggleCustom = (): void => { + if (isCustom) { + setIsCustom(false); + onChange(regularSelected); + } else { + setIsCustom(true); + onChange(customValue ? [...regularSelected, customValue] : regularSelected); + } + }; + + const updateCustomValue = (next: string): void => { + setCustomValue(next); + if (isCustom) { + onChange(next ? [...regularSelected, next] : regularSelected); + } + }; + return (
@@ -199,18 +323,35 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element { {required && *}
- {options.map((opt) => ( - + ))} + {allowCustom && ( + + {CUSTOM_OPTION_LABEL} + + )}
+ {isCustom && ( + updateCustomValue(e.target.value)} + /> + )}
); } @@ -222,7 +363,7 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element { {label} {required && *} - {isEditing ? ( - ( + pathname, + { path: ROUTES.DASHBOARD_WIDGET, exact: true }, + ); + if (widgetMatch) { + return [ + { + source: 'auto', + type: 'dashboard', + resourceId: widgetMatch.params.dashboardId, + metadata: { + page: 'panel_edit', + widgetId: widgetMatch.params.widgetId, + ...sharedMetadata, + }, + }, + ]; + } + + // Dashboard detail — `/dashboard/:dashboardId`. The `expandedWidgetId` + // query param signals the panel-fullscreen overlay; otherwise it's the + // plain dashboard view. + const dashboardMatch = matchPath<{ dashboardId: string }>(pathname, { + path: ROUTES.DASHBOARD, + exact: true, + }); + if (dashboardMatch) { + const expandedWidgetId = params.get(QueryParams.expandedWidgetId); + if (expandedWidgetId) { + return [ + { + source: 'auto', + type: 'dashboard', + resourceId: dashboardMatch.params.dashboardId, + metadata: { + page: 'panel_fullscreen', + widgetId: expandedWidgetId, + ...sharedMetadata, + }, + }, + ]; + } + return [ + { + source: 'auto', + type: 'dashboard', + resourceId: dashboardMatch.params.dashboardId, + metadata: { + page: 'dashboard_detail', + ...sharedMetadata, + }, + }, + ]; + } + + // Dashboard list — `/dashboard`. + if (matchPath(pathname, { path: ROUTES.ALL_DASHBOARD, exact: true })) { + return [ + { + source: 'auto', + type: 'dashboard', + resourceId: null, + metadata: { page: 'dashboard_list' }, + }, + ]; + } + + // ── Alerts ──────────────────────────────────────────────────────────────── + + // Alert edit — `/alerts/edit?ruleId=…`. + if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) { + const ruleId = params.get(QueryParams.ruleId); + if (ruleId) { + return [ + { + source: 'auto', + type: 'alert', + resourceId: ruleId, + metadata: { page: 'alert_edit' }, + }, + ]; + } + } + + if (matchPath(pathname, { path: ROUTES.ALERTS_NEW, exact: true })) { + return [ + { + source: 'auto', + type: 'alert', + resourceId: null, + metadata: { page: 'alert_new' }, + }, + ]; + } + + if (matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })) { + return [ + { + source: 'auto', + type: 'alert', + resourceId: null, + metadata: { + page: 'alerts_triggered', + ...sharedMetadata, + }, + }, + ]; + } + + if (matchPath(pathname, { path: ROUTES.LIST_ALL_ALERT, exact: true })) { + return [ + { + source: 'auto', + type: 'alert', + resourceId: null, + metadata: { page: 'alert_list' }, + }, + ]; + } + + // ── Services ────────────────────────────────────────────────────────────── + + // Service detail (covers sub-routes like top-level-operations) — + // `/services/:servicename[/...]`. + const serviceMatch = matchPath<{ servicename: string }>(pathname, { + path: ROUTES.SERVICE_METRICS, + exact: false, + }); + if (serviceMatch?.params.servicename) { + return [ + { + source: 'auto', + type: 'service', + resourceId: serviceMatch.params.servicename, + metadata: { + page: 'service_detail', + ...sharedMetadata, + }, + }, + ]; + } + + // Services list — `/services`. + if (matchPath(pathname, { path: ROUTES.APPLICATION, exact: true })) { + return [ + { + source: 'auto', + type: 'service', + resourceId: null, + metadata: { + page: 'services_list', + ...sharedMetadata, + }, + }, + ]; + } + + // ── Logs ────────────────────────────────────────────────────────────────── + + if (matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false })) { + const activeLogId = params.get(QueryParams.activeLogId); + // `?activeLogId=…` indicates a log-detail panel is open. Per the + // schema, log_detail requires payload fields (timestamp, service, + // body) we can't recover from the URL alone, so we still emit the + // surrounding logs_explorer page context — the panel registry can + // layer richer log_detail context on top in a follow-up. + return [ + { + source: 'auto', + type: 'logs_explorer', + resourceId: null, + metadata: { + page: 'logs_explorer', + ...sharedMetadata, + ...(activeLogId ? { activeLogId } : {}), + }, + }, + ]; + } + + // ── Traces ──────────────────────────────────────────────────────────────── + + // Trace detail — `/trace/:id`. Treated as a detail-as-metadata page + // (resourceId null, `traceId` lives in metadata). + const traceMatch = matchPath<{ id: string }>(pathname, { + path: ROUTES.TRACE_DETAIL, + exact: true, + }); + if (traceMatch?.params.id) { + const spanId = params.get(QueryParams.selectedTimelineQuery); + return [ + { + source: 'auto', + type: 'traces_explorer', + resourceId: null, + metadata: { + page: 'trace_detail', + traceId: traceMatch.params.id, + ...(spanId ? { spanId } : {}), + ...sharedMetadata, + }, + }, + ]; + } + + if (matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false })) { + return [ + { + source: 'auto', + type: 'traces_explorer', + resourceId: null, + metadata: { + page: 'traces_explorer', + ...sharedMetadata, + }, + }, + ]; + } + + // ── Metrics ─────────────────────────────────────────────────────────────── + + if ( + matchPath(pathname, { path: ROUTES.METRICS_EXPLORER_EXPLORER, exact: false }) + ) { + return [ + { + source: 'auto', + type: 'metrics_explorer', + resourceId: null, + metadata: { + page: 'metrics_explorer', + ...sharedMetadata, + }, + }, + ]; + } + + return []; +} + +/** + * Pulls metadata fields that any page may carry in its query string — + * `timeRange`, `query`, saved-view selectors, dashboard variables. Each + * piece is omitted when the URL doesn't carry it (rather than being filled + * in with a stale default). + */ +function collectSharedMetadata( + params: URLSearchParams, +): Record { + const out: Record = {}; + + // Time range — emit only when both bounds are explicit. SigNoz writes + // `startTime` / `endTime` in milliseconds when the user picks a custom + // range; relative ranges (`relativeTime=15m`) are left out because the + // server applies its own freshly-anchored window. + const startTime = numericParam(params, QueryParams.startTime); + const endTime = numericParam(params, QueryParams.endTime); + if (startTime !== null && endTime !== null) { + out.timeRange = { start: startTime, end: endTime }; + } + + // Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`. + const compositeQueryRaw = params.get(QueryParams.compositeQuery); + if (compositeQueryRaw) { + try { + out.query = JSON.parse(decodeURIComponent(compositeQueryRaw)); + } catch { + // Malformed JSON in the URL — drop silently rather than throw + // inside a context-collection helper. + } + } + + // Saved view selectors (logs / traces explorer) and dashboard variables. + const viewKey = params.get(QueryParams.viewKey); + if (viewKey) { + out.savedViewId = viewKey; + } + const viewName = params.get(QueryParams.viewName); + if (viewName) { + out.savedViewName = viewName; + } + const variablesRaw = params.get(QueryParams.variables); + if (variablesRaw) { + try { + out.variables = JSON.parse(decodeURIComponent(variablesRaw)); + } catch { + // Same fallback as compositeQuery — keep silent on malformed JSON. + } + } + + return out; +} + +function numericParam(params: URLSearchParams, key: string): number | null { + const raw = params.get(key); + if (raw === null) { + return null; + } + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} diff --git a/frontend/src/container/AIAssistant/store/useAIAssistantStore.ts b/frontend/src/container/AIAssistant/store/useAIAssistantStore.ts index f5b497eb6f..e3e4648087 100644 --- a/frontend/src/container/AIAssistant/store/useAIAssistantStore.ts +++ b/frontend/src/container/AIAssistant/store/useAIAssistantStore.ts @@ -287,14 +287,11 @@ async function runStreamingLoop( set((s) => { const st = s.streams[conversationId]; if (st) { - st.pendingApproval = { - approvalId: event.approvalId, - executionId: event.executionId, - actionType: event.actionType, - resourceType: event.resourceType, - summary: event.summary, - diff: event.diff ?? null, - }; + // The SSE event is already an `ApprovalEventDTO`, the same + // shape the slot is typed against — pass through verbatim + // rather than re-projecting (which previously dropped any + // fields the projection didn't enumerate explicitly). + st.pendingApproval = event; st.streamingStatus = 'awaiting_approval'; st.isStreaming = false; } @@ -304,20 +301,11 @@ async function runStreamingLoop( set((s) => { const st = s.streams[conversationId]; if (st) { - st.pendingClarification = { - clarificationId: event.clarificationId, - executionId: event.executionId, - message: event.message, - discoveredContext: event.discoveredContext ?? null, - fields: (event.fields ?? []).map((f) => ({ - id: f.id, - type: f.type, - label: f.label, - required: f.required ?? false, - options: f.options ?? null, - default: f.default ?? null, - })), - }; + // Same rationale as the approval branch — `event` is a + // `ClarificationEventDTO` whose `fields` already carry + // `allowCustom` (which the previous manual projection + // silently stripped). + st.pendingClarification = event; st.streamingStatus = 'awaiting_clarification'; st.isStreaming = false; } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 86abd2a7a2..1ab477cdac 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4496,10 +4496,10 @@ tailwind-merge "^2.5.2" tailwindcss-animate "^1.0.7" -"@signozhq/ui@0.0.10": - version "0.0.10" - resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.10.tgz#cdbab838f8cb543cf5b483a86e9d9b65265b81ff" - integrity sha512-XLeET+PgSP7heqKMsb9YZOSRT3TpfMPHNQRnY1I4SK8mXSct7BYWwK0Q3Je0uf4Z3aWOcpRYoRUPHWZQBpweFQ== +"@signozhq/ui@0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.13.tgz#63e64d203df662a84ffa38ea18534ba2941fd051" + integrity sha512-YFFFNTnF9ZG9ddKhFV6feQRLUBg6vuDTcvWaStzfOCS0xF3ocd+Lpg73TpFkKidLx/Ij/TJm5u/tTobkdLyC8A== dependencies: "@chenglou/pretext" "^0.0.5" "@radix-ui/react-checkbox" "^1.2.3" @@ -4521,7 +4521,7 @@ clsx "^2.1.1" cmdk "^1.1.1" dayjs "^1.11.10" - lodash-es "^4.17.21" + lodash-es "^4.18.1" motion "^11.11.17" next-themes "^0.4.6" nuqs "^2.8.9" @@ -11914,6 +11914,11 @@ lodash-es@4, lodash-es@^4.17.21: resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash-es@^4.18.1: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== + lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz"