mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-21 19:30:29 +01:00
Compare commits
42 Commits
feat/ai-as
...
base-path-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d94a4747b | ||
|
|
b256f83a6e | ||
|
|
8513c22bf5 | ||
|
|
bf5c49a2a4 | ||
|
|
02dcbae9b5 | ||
|
|
32ff1e6924 | ||
|
|
9f63f8a506 | ||
|
|
49b7bc2e5b | ||
|
|
3a5d3225dd | ||
|
|
b61376b904 | ||
|
|
18429b3b91 | ||
|
|
c7adb22c61 | ||
|
|
b936e63658 | ||
|
|
727b105b6c | ||
|
|
f3269318b7 | ||
|
|
5088bd7499 | ||
|
|
7648d4f3d3 | ||
|
|
5729a4584a | ||
|
|
825d06249d | ||
|
|
9034471587 | ||
|
|
4cc23ead6b | ||
|
|
867e27d45f | ||
|
|
be37e588f8 | ||
|
|
057dcbe6e4 | ||
|
|
3a28d741a3 | ||
|
|
223e83154f | ||
|
|
50ae51cdaa | ||
|
|
c8ae8476c3 | ||
|
|
daaa66e1fc | ||
|
|
b0717d6a69 | ||
|
|
4aefe44313 | ||
|
|
4dc6f6fe7b | ||
|
|
d3e0c46ba2 | ||
|
|
0fed17e11a | ||
|
|
a2264b4960 | ||
|
|
2740964106 | ||
|
|
0ca22dd7fe | ||
|
|
a3b6bddac8 | ||
|
|
d908ce321a | ||
|
|
c221a44f3d | ||
|
|
22fb4daaf9 | ||
|
|
1bdc059d76 |
64
.github/CODEOWNERS
vendored
64
.github/CODEOWNERS
vendored
@@ -16,38 +16,38 @@ go.mod @therealpandey
|
||||
|
||||
# Scaffold Owners
|
||||
|
||||
/pkg/config/ @therealpandey
|
||||
/pkg/errors/ @therealpandey
|
||||
/pkg/factory/ @therealpandey
|
||||
/pkg/types/ @therealpandey
|
||||
/pkg/valuer/ @therealpandey
|
||||
/cmd/ @therealpandey
|
||||
.golangci.yml @therealpandey
|
||||
/pkg/config/ @vikrantgupta25
|
||||
/pkg/errors/ @vikrantgupta25
|
||||
/pkg/factory/ @vikrantgupta25
|
||||
/pkg/types/ @vikrantgupta25
|
||||
/pkg/valuer/ @vikrantgupta25
|
||||
/cmd/ @vikrantgupta25
|
||||
.golangci.yml @vikrantgupta25
|
||||
|
||||
# Zeus Owners
|
||||
|
||||
/pkg/zeus/ @therealpandey
|
||||
/ee/zeus/ @therealpandey
|
||||
/pkg/licensing/ @therealpandey
|
||||
/ee/licensing/ @therealpandey
|
||||
/pkg/zeus/ @vikrantgupta25
|
||||
/ee/zeus/ @vikrantgupta25
|
||||
/pkg/licensing/ @vikrantgupta25
|
||||
/ee/licensing/ @vikrantgupta25
|
||||
|
||||
# SQL Owners
|
||||
|
||||
/pkg/sqlmigration/ @therealpandey
|
||||
/ee/sqlmigration/ @therealpandey
|
||||
/pkg/sqlschema/ @therealpandey
|
||||
/ee/sqlschema/ @therealpandey
|
||||
/pkg/sqlmigration/ @vikrantgupta25
|
||||
/ee/sqlmigration/ @vikrantgupta25
|
||||
/pkg/sqlschema/ @vikrantgupta25
|
||||
/ee/sqlschema/ @vikrantgupta25
|
||||
|
||||
# Analytics Owners
|
||||
|
||||
/pkg/analytics/ @therealpandey
|
||||
/pkg/statsreporter/ @therealpandey
|
||||
/pkg/analytics/ @vikrantgupta25
|
||||
/pkg/statsreporter/ @vikrantgupta25
|
||||
|
||||
# Emailing Owners
|
||||
|
||||
/pkg/emailing/ @therealpandey
|
||||
/pkg/types/emailtypes/ @therealpandey
|
||||
/templates/email/ @therealpandey
|
||||
/pkg/emailing/ @vikrantgupta25
|
||||
/pkg/types/emailtypes/ @vikrantgupta25
|
||||
/templates/email/ @vikrantgupta25
|
||||
|
||||
# Querier Owners
|
||||
|
||||
@@ -97,23 +97,23 @@ go.mod @therealpandey
|
||||
|
||||
# AuthN / AuthZ Owners
|
||||
|
||||
/pkg/authz/ @therealpandey
|
||||
/ee/authz/ @therealpandey
|
||||
/pkg/authn/ @therealpandey
|
||||
/ee/authn/ @therealpandey
|
||||
/pkg/modules/user/ @therealpandey
|
||||
/pkg/modules/session/ @therealpandey
|
||||
/pkg/modules/organization/ @therealpandey
|
||||
/pkg/modules/authdomain/ @therealpandey
|
||||
/pkg/modules/role/ @therealpandey
|
||||
/pkg/authz/ @vikrantgupta25
|
||||
/ee/authz/ @vikrantgupta25
|
||||
/pkg/authn/ @vikrantgupta25
|
||||
/ee/authn/ @vikrantgupta25
|
||||
/pkg/modules/user/ @vikrantgupta25
|
||||
/pkg/modules/session/ @vikrantgupta25
|
||||
/pkg/modules/organization/ @vikrantgupta25
|
||||
/pkg/modules/authdomain/ @vikrantgupta25
|
||||
/pkg/modules/role/ @vikrantgupta25
|
||||
|
||||
# IdentN Owners
|
||||
/pkg/identn/ @therealpandey
|
||||
/pkg/http/middleware/identn.go @therealpandey
|
||||
/pkg/identn/ @vikrantgupta25
|
||||
/pkg/http/middleware/identn.go @vikrantgupta25
|
||||
|
||||
# Integration tests
|
||||
|
||||
/tests/integration/ @therealpandey
|
||||
/tests/integration/ @vikrantgupta25
|
||||
|
||||
# OpenAPI types generator
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
|
||||
}
|
||||
|
||||
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
|
||||
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
|
||||
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.Aws.DeploymentRegion)
|
||||
u, _ := url.Parse(baseURL)
|
||||
|
||||
q := u.Query()
|
||||
q.Set("region", req.Config.AWS.DeploymentRegion)
|
||||
q.Set("region", req.Config.Aws.DeploymentRegion)
|
||||
u.Fragment = "/stacks/quickcreate"
|
||||
|
||||
u.RawQuery = q.Encode()
|
||||
@@ -39,7 +39,9 @@ func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, acc
|
||||
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
|
||||
|
||||
return &cloudintegrationtypes.ConnectionArtifact{
|
||||
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
|
||||
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
|
||||
ConnectionURL: u.String() + "?&" + q.Encode(), // this format is required by AWS
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -122,6 +124,9 @@ func (provider *awscloudprovider) BuildIntegrationConfig(
|
||||
}
|
||||
|
||||
return &cloudintegrationtypes.ProviderIntegrationConfig{
|
||||
AWS: cloudintegrationtypes.NewAWSIntegrationConfig(account.Config.AWS.Regions, collectionStrategy),
|
||||
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
|
||||
EnabledRegions: account.Config.AWS.Regions,
|
||||
TelemetryCollectionStrategy: collectionStrategy,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ module.exports = {
|
||||
rules: {
|
||||
// Asset migration — base-path safety
|
||||
'rulesdir/no-unsupported-asset-pattern': 'error',
|
||||
// Base-path safety — window.open and origin-concat patterns
|
||||
'rulesdir/no-raw-absolute-path': 'error',
|
||||
|
||||
// Code quality rules
|
||||
'prefer-const': 'error', // Enforces const for variables never reassigned
|
||||
@@ -213,6 +215,31 @@ module.exports = {
|
||||
message:
|
||||
'Avoid calling .getState() directly. Export a standalone action from the store instead.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"MemberExpression[object.name='window'][property.name='localStorage']",
|
||||
message:
|
||||
'Use getLocalStorageKey/setLocalStorageKey/removeLocalStorageKey from api/browser/localstorage instead.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"MemberExpression[object.name='window'][property.name='sessionStorage']",
|
||||
message:
|
||||
'Use getSessionStorageApi/setSessionStorageApi/removeSessionStorageApi from api/browser/sessionstorage instead.',
|
||||
},
|
||||
],
|
||||
'no-restricted-globals': [
|
||||
'error',
|
||||
{
|
||||
name: 'localStorage',
|
||||
message:
|
||||
'Use getLocalStorageKey/setLocalStorageKey/removeLocalStorageKey from api/browser/localstorage instead.',
|
||||
},
|
||||
{
|
||||
name: 'sessionStorage',
|
||||
message:
|
||||
'Use getSessionStorageApi/setSessionStorageApi/removeSessionStorageApi from api/browser/sessionstorage instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
@@ -246,6 +273,11 @@ module.exports = {
|
||||
'sonarjs/cognitive-complexity': 'off', // Tests can be complex
|
||||
'sonarjs/no-identical-functions': 'off', // Similar test patterns are OK
|
||||
'sonarjs/no-small-switch': 'off', // Small switches are OK in tests
|
||||
// Test assertions intentionally reference window.location.origin for expected-value checks
|
||||
'rulesdir/no-raw-absolute-path': 'off',
|
||||
// Tests may access storage directly for setup/assertion/spy purposes
|
||||
'no-restricted-globals': 'off',
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
153
frontend/eslint-rules/no-raw-absolute-path.js
Normal file
153
frontend/eslint-rules/no-raw-absolute-path.js
Normal file
@@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ESLint rule: no-raw-absolute-path
|
||||
*
|
||||
* Catches patterns that break at runtime when the app is served from a
|
||||
* sub-path (e.g. /signoz/):
|
||||
*
|
||||
* 1. window.open(path, '_blank')
|
||||
* → use openInNewTab(path) which calls withBasePath internally
|
||||
*
|
||||
* 2. window.location.origin + path / `${window.location.origin}${path}`
|
||||
* → use getAbsoluteUrl(path)
|
||||
*
|
||||
* 3. frontendBaseUrl: window.location.origin (bare origin usage)
|
||||
* → use getBaseUrl() to include the base path
|
||||
*
|
||||
* 4. window.location.href = path
|
||||
* → use withBasePath(path) or navigate() for internal navigation
|
||||
*
|
||||
* External URLs (first arg starts with "http") are explicitly allowed.
|
||||
*/
|
||||
|
||||
function isOriginAccess(node) {
|
||||
return (
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property.name === 'origin' &&
|
||||
node.object.type === 'MemberExpression' &&
|
||||
!node.object.computed &&
|
||||
node.object.property.name === 'location' &&
|
||||
node.object.object.type === 'Identifier' &&
|
||||
node.object.object.name === 'window'
|
||||
);
|
||||
}
|
||||
|
||||
function isHrefAccess(node) {
|
||||
return (
|
||||
node.type === 'MemberExpression' &&
|
||||
!node.computed &&
|
||||
node.property.name === 'href' &&
|
||||
node.object.type === 'MemberExpression' &&
|
||||
!node.object.computed &&
|
||||
node.object.property.name === 'location' &&
|
||||
node.object.object.type === 'Identifier' &&
|
||||
node.object.object.name === 'window'
|
||||
);
|
||||
}
|
||||
|
||||
function isExternalUrl(node) {
|
||||
if (node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value.startsWith('http://') || node.value.startsWith('https://');
|
||||
}
|
||||
if (node.type === 'TemplateLiteral' && node.quasis.length > 0) {
|
||||
const raw = node.quasis[0].value.raw;
|
||||
return raw.startsWith('http://') || raw.startsWith('https://');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// window.open(withBasePath(x)) and window.open(getAbsoluteUrl(x)) are already safe.
|
||||
function isSafeHelperCall(node) {
|
||||
return (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee.type === 'Identifier' &&
|
||||
(node.callee.name === 'withBasePath' || node.callee.name === 'getAbsoluteUrl')
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow raw window.open and origin-concatenation patterns that miss the runtime base path',
|
||||
category: 'Base Path Safety',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
windowOpen:
|
||||
'Use openInNewTab(path) instead of window.open(path, "_blank") — openInNewTab prepends the base path automatically.',
|
||||
originConcat:
|
||||
'Use getAbsoluteUrl(path) instead of window.location.origin + path — getAbsoluteUrl prepends the base path automatically.',
|
||||
originDirect:
|
||||
'Use getBaseUrl() instead of window.location.origin — getBaseUrl includes the base path.',
|
||||
hrefAssign:
|
||||
'Use withBasePath(path) or navigate() instead of window.location.href = path — ensures the base path is included.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
// window.open(path, ...) — allow only external first-arg URLs
|
||||
CallExpression(node) {
|
||||
const { callee, arguments: args } = node;
|
||||
if (
|
||||
callee.type !== 'MemberExpression' ||
|
||||
callee.object.type !== 'Identifier' ||
|
||||
callee.object.name !== 'window' ||
|
||||
callee.property.name !== 'open'
|
||||
)
|
||||
return;
|
||||
if (args.length < 1) return;
|
||||
if (isExternalUrl(args[0])) return;
|
||||
if (isSafeHelperCall(args[0])) return;
|
||||
|
||||
context.report({ node, messageId: 'windowOpen' });
|
||||
},
|
||||
|
||||
// window.location.origin + path
|
||||
BinaryExpression(node) {
|
||||
if (node.operator !== '+') return;
|
||||
if (isOriginAccess(node.left) || isOriginAccess(node.right)) {
|
||||
context.report({ node, messageId: 'originConcat' });
|
||||
}
|
||||
},
|
||||
|
||||
// `${window.location.origin}${path}`
|
||||
TemplateLiteral(node) {
|
||||
if (node.expressions.some(isOriginAccess)) {
|
||||
context.report({ node, messageId: 'originConcat' });
|
||||
}
|
||||
},
|
||||
|
||||
// window.location.origin used directly (not in concatenation)
|
||||
// Catches: frontendBaseUrl: window.location.origin
|
||||
MemberExpression(node) {
|
||||
if (!isOriginAccess(node)) return;
|
||||
|
||||
const parent = node.parent;
|
||||
// Skip if parent is BinaryExpression with + (handled by BinaryExpression visitor)
|
||||
if (parent.type === 'BinaryExpression' && parent.operator === '+') return;
|
||||
// Skip if inside TemplateLiteral (handled by TemplateLiteral visitor)
|
||||
if (parent.type === 'TemplateLiteral') return;
|
||||
|
||||
context.report({ node, messageId: 'originDirect' });
|
||||
},
|
||||
|
||||
// window.location.href = path
|
||||
AssignmentExpression(node) {
|
||||
if (node.operator !== '=') return;
|
||||
if (!isHrefAccess(node.left)) return;
|
||||
|
||||
// Allow external URLs
|
||||
if (isExternalUrl(node.right)) return;
|
||||
// Allow safe helper calls
|
||||
if (isSafeHelperCall(node.right)) return;
|
||||
|
||||
context.report({ node, messageId: 'hrefAssign' });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<base href="[[.BaseHref]]" />
|
||||
<meta
|
||||
http-equiv="Cache-Control"
|
||||
content="no-cache, no-store, must-revalidate, max-age: 0"
|
||||
@@ -39,12 +40,12 @@
|
||||
<meta
|
||||
data-react-helmet="true"
|
||||
property="og:image"
|
||||
content="/images/signoz-hero-image.webp"
|
||||
content="[[.BaseHref]]images/signoz-hero-image.webp"
|
||||
/>
|
||||
<meta
|
||||
data-react-helmet="true"
|
||||
name="twitter:image"
|
||||
content="/images/signoz-hero-image.webp"
|
||||
content="[[.BaseHref]]images/signoz-hero-image.webp"
|
||||
/>
|
||||
<meta
|
||||
data-react-helmet="true"
|
||||
@@ -59,7 +60,7 @@
|
||||
<meta data-react-helmet="true" name="docusaurus_locale" content="en" />
|
||||
<meta data-react-helmet="true" name="docusaurus_tag" content="default" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<link data-react-helmet="true" rel="shortcut icon" href="/favicon.ico" />
|
||||
<link data-react-helmet="true" rel="shortcut icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body data-theme="default">
|
||||
<script>
|
||||
@@ -136,7 +137,7 @@
|
||||
})(document, 'script');
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/css/uPlot.min.css" />
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
<script type="module" src="./src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
39763
frontend/package-lock.json
generated
39763
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -62,8 +62,7 @@
|
||||
"@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/table": "0.3.7",
|
||||
"@signozhq/toggle-group": "0.0.3",
|
||||
"@signozhq/ui": "0.0.5",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
@@ -126,7 +125,6 @@
|
||||
"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",
|
||||
@@ -150,7 +148,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -221,8 +221,7 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (
|
||||
pathname === ROUTES.ONBOARDING ||
|
||||
pathname.startsWith('/public/dashboard/') ||
|
||||
pathname.startsWith('/ai-assistant/')
|
||||
pathname.startsWith('/public/dashboard/')
|
||||
) {
|
||||
window.Pylon?.('hideChatBubble');
|
||||
} else {
|
||||
|
||||
@@ -244,18 +244,12 @@ export const ShortcutsPage = Loadable(
|
||||
() => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const Integrations = Loadable(
|
||||
export const InstalledIntegrations = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "InstalledIntegrations" */ 'pages/IntegrationsModulePage'
|
||||
),
|
||||
);
|
||||
export const IntegrationsDetailsPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "IntegrationsDetailsPage" */ 'pages/IntegrationsDetailsPage'
|
||||
),
|
||||
);
|
||||
|
||||
export const MessagingQueuesMainPage = Loadable(
|
||||
() =>
|
||||
@@ -317,10 +311,3 @@ export const MeterExplorerPage = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
|
||||
);
|
||||
|
||||
export const AIAssistantPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { RouteProps } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import {
|
||||
AIAssistantPage,
|
||||
AlertHistory,
|
||||
AlertOverview,
|
||||
AlertTypeSelectionPage,
|
||||
@@ -19,8 +18,7 @@ import {
|
||||
ForgotPassword,
|
||||
Home,
|
||||
InfrastructureMonitoring,
|
||||
Integrations,
|
||||
IntegrationsDetailsPage,
|
||||
InstalledIntegrations,
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LiveLogs,
|
||||
@@ -391,17 +389,10 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
key: 'WORKSPACE_ACCESS_RESTRICTED',
|
||||
},
|
||||
{
|
||||
path: ROUTES.INTEGRATIONS_DETAIL,
|
||||
exact: true,
|
||||
component: IntegrationsDetailsPage,
|
||||
isPrivate: true,
|
||||
key: 'INTEGRATIONS_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.INTEGRATIONS,
|
||||
exact: true,
|
||||
component: Integrations,
|
||||
component: InstalledIntegrations,
|
||||
isPrivate: true,
|
||||
key: 'INTEGRATIONS',
|
||||
},
|
||||
@@ -497,13 +488,6 @@ 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 = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { initReactI18next } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import Backend from 'i18next-http-backend';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
|
||||
import cacheBursting from '../../i18n-translations-hash.json';
|
||||
|
||||
@@ -24,7 +25,7 @@ i18n
|
||||
const ns = namespace[0];
|
||||
const pathkey = `/${language}/${ns}`;
|
||||
const hash = cacheBursting[pathkey as keyof typeof cacheBursting] || '';
|
||||
return `/locales/${language}/${namespace}.json?h=${hash}`;
|
||||
return `${getBasePath()}locales/${language}/${namespace}.json?h=${hash}`;
|
||||
},
|
||||
},
|
||||
react: {
|
||||
|
||||
19
frontend/src/api/Integrations/removeAwsIntegrationAccount.ts
Normal file
19
frontend/src/api/Integrations/removeAwsIntegrationAccount.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
const removeAwsIntegrationAccount = async (
|
||||
accountId: string,
|
||||
): Promise<SuccessResponse<Record<string, never>> | ErrorResponse> => {
|
||||
const response = await axios.post(
|
||||
`/cloud-integrations/aws/accounts/${accountId}/disconnect`,
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data.data,
|
||||
};
|
||||
};
|
||||
|
||||
export default removeAwsIntegrationAccount;
|
||||
@@ -1,467 +0,0 @@
|
||||
/**
|
||||
* 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 to override.
|
||||
const AI_BACKEND =
|
||||
import.meta.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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
import { getScopedKey } from 'utils/storage';
|
||||
|
||||
const get = (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
return localStorage.getItem(getScopedKey(key));
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
import { getScopedKey } from 'utils/storage';
|
||||
|
||||
const remove = (key: string): boolean => {
|
||||
try {
|
||||
window.localStorage.removeItem(key);
|
||||
localStorage.removeItem(getScopedKey(key));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
import { getScopedKey } from 'utils/storage';
|
||||
|
||||
const set = (key: string, value: string): boolean => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
localStorage.setItem(getScopedKey(key), value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
|
||||
12
frontend/src/api/browser/sessionstorage/get.ts
Normal file
12
frontend/src/api/browser/sessionstorage/get.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
import { getScopedKey } from 'utils/storage';
|
||||
|
||||
const get = (key: string): string | null => {
|
||||
try {
|
||||
return sessionStorage.getItem(getScopedKey(key));
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export default get;
|
||||
13
frontend/src/api/browser/sessionstorage/remove.ts
Normal file
13
frontend/src/api/browser/sessionstorage/remove.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
import { getScopedKey } from 'utils/storage';
|
||||
|
||||
const remove = (key: string): boolean => {
|
||||
try {
|
||||
sessionStorage.removeItem(getScopedKey(key));
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default remove;
|
||||
13
frontend/src/api/browser/sessionstorage/set.ts
Normal file
13
frontend/src/api/browser/sessionstorage/set.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable no-restricted-globals */
|
||||
import { getScopedKey } from 'utils/storage';
|
||||
|
||||
const set = (key: string, value: string): boolean => {
|
||||
try {
|
||||
sessionStorage.setItem(getScopedKey(key), value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default set;
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
interceptorRejected,
|
||||
interceptorsRequestBasePath,
|
||||
interceptorsRequestResponse,
|
||||
interceptorsResponse,
|
||||
} from 'api';
|
||||
@@ -17,6 +18,7 @@ export const GeneratedAPIInstance = <T>(
|
||||
return generatedAPIAxiosInstance({ ...config }).then(({ data }) => data);
|
||||
};
|
||||
|
||||
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
generatedAPIAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
generatedAPIAxiosInstance.interceptors.response.use(
|
||||
interceptorsResponse,
|
||||
|
||||
@@ -11,6 +11,7 @@ import axios, {
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { Events } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { getBasePath } from 'utils/basePath';
|
||||
import { eventEmitter } from 'utils/getEventEmitter';
|
||||
|
||||
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
|
||||
@@ -67,6 +68,39 @@ export const interceptorsRequestResponse = (
|
||||
return value;
|
||||
};
|
||||
|
||||
// Strips the leading '/' from path and joins with base — idempotent if already prefixed.
|
||||
// e.g. prependBase('/signoz/', '/api/v1/') → '/signoz/api/v1/'
|
||||
function prependBase(base: string, path: string): string {
|
||||
return path.startsWith(base) ? path : base + path.slice(1);
|
||||
}
|
||||
|
||||
// Prepends the runtime base path to outgoing requests so API calls work under
|
||||
// a URL prefix (e.g. /signoz/api/v1/…). No-op for root deployments and dev
|
||||
// (dev baseURL is a full http:// URL, not an absolute path).
|
||||
export const interceptorsRequestBasePath = (
|
||||
value: InternalAxiosRequestConfig,
|
||||
): InternalAxiosRequestConfig => {
|
||||
const basePath = getBasePath();
|
||||
if (basePath === '/') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value.baseURL?.startsWith('/')) {
|
||||
// Production relative baseURL: '/api/v1/' → '/signoz/api/v1/'
|
||||
value.baseURL = prependBase(basePath, value.baseURL);
|
||||
} else if (value.baseURL?.startsWith('http')) {
|
||||
// Dev absolute baseURL (VITE_FRONTEND_API_ENDPOINT): 'https://host/api/v1/' → 'https://host/signoz/api/v1/'
|
||||
const url = new URL(value.baseURL);
|
||||
url.pathname = prependBase(basePath, url.pathname);
|
||||
value.baseURL = url.toString();
|
||||
} else if (!value.baseURL && value.url?.startsWith('/')) {
|
||||
// Orval-generated client (empty baseURL, path in url): '/api/signoz/v1/rules' → '/signoz/api/signoz/v1/rules'
|
||||
value.url = prependBase(basePath, value.url);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const interceptorRejected = async (
|
||||
value: AxiosResponse<any>,
|
||||
): Promise<AxiosResponse<any>> => {
|
||||
@@ -133,6 +167,7 @@ const instance = axios.create({
|
||||
});
|
||||
|
||||
instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
instance.interceptors.response.use(interceptorsResponse, interceptorRejected);
|
||||
|
||||
export const AxiosAlertManagerInstance = axios.create({
|
||||
@@ -147,6 +182,7 @@ ApiV2Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV2Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV2Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
|
||||
// axios V3
|
||||
export const ApiV3Instance = axios.create({
|
||||
@@ -158,6 +194,7 @@ ApiV3Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV3Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV3Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios V4
|
||||
@@ -170,6 +207,7 @@ ApiV4Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV4Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV4Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios V5
|
||||
@@ -182,6 +220,7 @@ ApiV5Instance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
|
||||
ApiV5Instance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
// axios Base
|
||||
@@ -194,6 +233,7 @@ LogEventAxiosInstance.interceptors.response.use(
|
||||
interceptorRejectedBase,
|
||||
);
|
||||
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
//
|
||||
|
||||
AxiosAlertManagerInstance.interceptors.response.use(
|
||||
@@ -201,6 +241,7 @@ AxiosAlertManagerInstance.interceptors.response.use(
|
||||
interceptorRejected,
|
||||
);
|
||||
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||
AxiosAlertManagerInstance.interceptors.request.use(interceptorsRequestBasePath);
|
||||
|
||||
export { apiV1 };
|
||||
export default instance;
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import {
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
export const getHostAttributeKeys = async (
|
||||
searchText = '',
|
||||
entity: InfraMonitoringEntity,
|
||||
entity: K8sCategory,
|
||||
): Promise<SuccessResponse<IQueryAutocompleteResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const response: AxiosResponse<{
|
||||
|
||||
@@ -33,8 +33,6 @@ export interface HostData {
|
||||
hostName: string;
|
||||
active: boolean;
|
||||
os: string;
|
||||
/** Present when the list API returns grouped rows or extra resource attributes. */
|
||||
meta?: Record<string, string>;
|
||||
cpu: number;
|
||||
cpuTimeSeries: TimeSeries;
|
||||
memory: number;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sNodesListPayload {
|
||||
export interface K8sClustersListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
@@ -19,24 +18,23 @@ export interface K8sNodesListPayload {
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sNodeData {
|
||||
nodeUID: string;
|
||||
nodeCPUUsage: number;
|
||||
nodeCPUAllocatable: number;
|
||||
nodeMemoryUsage: number;
|
||||
nodeMemoryAllocatable: number;
|
||||
export interface K8sClustersData {
|
||||
clusterUID: string;
|
||||
cpuUsage: number;
|
||||
cpuAllocatable: number;
|
||||
memoryUsage: number;
|
||||
memoryAllocatable: number;
|
||||
meta: {
|
||||
k8s_node_name: string;
|
||||
k8s_node_uid: string;
|
||||
k8s_cluster_name: string;
|
||||
k8s_cluster_uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sNodesListResponse {
|
||||
export interface K8sClustersListResponse {
|
||||
status: string;
|
||||
data: {
|
||||
type: string;
|
||||
records: K8sNodeData[];
|
||||
records: K8sClustersData[];
|
||||
groups: null;
|
||||
total: number;
|
||||
sentAnyHostMetricsData: boolean;
|
||||
@@ -44,32 +42,30 @@ export interface K8sNodesListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const nodesMetaMap = [
|
||||
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
|
||||
{ dot: 'k8s.node.uid', under: 'k8s_node_uid' },
|
||||
export const clustersMetaMap = [
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
{ dot: 'k8s.cluster.uid', under: 'k8s_cluster_uid' },
|
||||
] as const;
|
||||
|
||||
export function mapNodesMeta(
|
||||
export function mapClustersMeta(
|
||||
raw: Record<string, unknown>,
|
||||
): K8sNodeData['meta'] {
|
||||
): K8sClustersData['meta'] {
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
nodesMetaMap.forEach(({ dot, under }) => {
|
||||
clustersMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in raw) {
|
||||
const v = raw[dot];
|
||||
out[under] = typeof v === 'string' ? v : raw[under];
|
||||
}
|
||||
});
|
||||
return out as K8sNodeData['meta'];
|
||||
return out as K8sClustersData['meta'];
|
||||
}
|
||||
|
||||
export const getK8sNodesList = async (
|
||||
props: K8sBaseFilters,
|
||||
export const getK8sClustersList = async (
|
||||
props: K8sClustersListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sNodesListResponse> | ErrorResponse> => {
|
||||
): Promise<SuccessResponse<K8sClustersListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
@@ -104,16 +100,16 @@ export const getK8sNodesList = async (
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/nodes/list', requestProps, {
|
||||
const response = await axios.post('/clusters/list', requestProps, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
const payload: K8sNodesListResponse = response.data;
|
||||
const payload: K8sClustersListResponse = response.data;
|
||||
|
||||
// one-liner to map dot→underscore
|
||||
// one-liner meta mapping
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapNodesMeta(record.meta as Record<string, unknown>),
|
||||
meta: mapClustersMeta(record.meta as Record<string, unknown>),
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -1,10 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sDaemonSetsListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sDaemonSetsData {
|
||||
daemonSetName: string;
|
||||
@@ -36,7 +48,6 @@ export interface K8sDaemonSetsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const daemonSetsMetaMap = [
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
|
||||
@@ -57,43 +68,45 @@ export function mapDaemonSetsMeta(
|
||||
}
|
||||
|
||||
export const getK8sDaemonSetsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sDaemonSetsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sDaemonSetsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
// filter prep (unchanged)…
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/daemonsets/list', requestProps, {
|
||||
signal,
|
||||
@@ -101,6 +114,7 @@ export const getK8sDaemonSetsList = async (
|
||||
});
|
||||
const payload: K8sDaemonSetsListResponse = response.data;
|
||||
|
||||
// single-line meta mapping
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapDaemonSetsMeta(record.meta as Record<string, unknown>),
|
||||
@@ -1,12 +1,11 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sDeploymentsListPayload {
|
||||
filters: TagFilter;
|
||||
@@ -49,7 +48,6 @@ export interface K8sDeploymentsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const deploymentsMetaMap = [
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
|
||||
@@ -70,43 +68,44 @@ export function mapDeploymentsMeta(
|
||||
}
|
||||
|
||||
export const getK8sDeploymentsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sDeploymentsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sDeploymentsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/deployments/list', requestProps, {
|
||||
signal,
|
||||
@@ -114,6 +113,7 @@ export const getK8sDeploymentsList = async (
|
||||
});
|
||||
const payload: K8sDeploymentsListResponse = response.data;
|
||||
|
||||
// single-line mapping
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapDeploymentsMeta(record.meta as Record<string, unknown>),
|
||||
@@ -1,10 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sJobsListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sJobsData {
|
||||
jobName: string;
|
||||
@@ -38,7 +50,6 @@ export interface K8sJobsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const jobsMetaMap = [
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
|
||||
@@ -57,43 +68,44 @@ export function mapJobsMeta(raw: Record<string, unknown>): K8sJobsData['meta'] {
|
||||
}
|
||||
|
||||
export const getK8sJobsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sJobsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sJobsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters?.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/jobs/list', requestProps, {
|
||||
signal,
|
||||
@@ -101,6 +113,7 @@ export const getK8sJobsList = async (
|
||||
});
|
||||
const payload: K8sJobsListResponse = response.data;
|
||||
|
||||
// one-liner meta mapping
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapJobsMeta(record.meta as Record<string, unknown>),
|
||||
@@ -1,12 +1,11 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sNamespacesListPayload {
|
||||
filters: TagFilter;
|
||||
@@ -41,7 +40,6 @@ export interface K8sNamespacesListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const namespacesMetaMap = [
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
@@ -61,43 +59,44 @@ export function mapNamespacesMeta(
|
||||
}
|
||||
|
||||
export const getK8sNamespacesList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sNamespacesListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sNamespacesListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters?.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/namespaces/list', requestProps, {
|
||||
signal,
|
||||
@@ -0,0 +1,127 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sNodesListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sNodesData {
|
||||
nodeUID: string;
|
||||
nodeCPUUsage: number;
|
||||
nodeCPUAllocatable: number;
|
||||
nodeMemoryUsage: number;
|
||||
nodeMemoryAllocatable: number;
|
||||
meta: {
|
||||
k8s_node_name: string;
|
||||
k8s_node_uid: string;
|
||||
k8s_cluster_name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sNodesListResponse {
|
||||
status: string;
|
||||
data: {
|
||||
type: string;
|
||||
records: K8sNodesData[];
|
||||
groups: null;
|
||||
total: number;
|
||||
sentAnyHostMetricsData: boolean;
|
||||
isSendingK8SAgentMetrics: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const nodesMetaMap = [
|
||||
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
|
||||
{ dot: 'k8s.node.uid', under: 'k8s_node_uid' },
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
] as const;
|
||||
|
||||
export function mapNodesMeta(
|
||||
raw: Record<string, unknown>,
|
||||
): K8sNodesData['meta'] {
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
nodesMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in raw) {
|
||||
const v = raw[dot];
|
||||
out[under] = typeof v === 'string' ? v : raw[under];
|
||||
}
|
||||
});
|
||||
return out as K8sNodesData['meta'];
|
||||
}
|
||||
|
||||
export const getK8sNodesList = async (
|
||||
props: K8sNodesListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sNodesListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/nodes/list', requestProps, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
const payload: K8sNodesListResponse = response.data;
|
||||
|
||||
// one-liner to map dot→underscore
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapNodesMeta(record.meta as Record<string, unknown>),
|
||||
}));
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload,
|
||||
params: requestProps,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,36 +1,21 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const podsMetaMap = [
|
||||
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
|
||||
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
|
||||
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
|
||||
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
|
||||
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
|
||||
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
|
||||
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
] as const;
|
||||
|
||||
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
|
||||
// clone everything
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
// overlay only the dot→under mappings
|
||||
podsMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in raw) {
|
||||
const v = raw[dot];
|
||||
out[under] = typeof v === 'string' ? v : raw[under];
|
||||
}
|
||||
});
|
||||
return out as K8sPodsData['meta'];
|
||||
export interface K8sPodsListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimeSeriesValue {
|
||||
@@ -86,44 +71,72 @@ export interface K8sPodsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export const podsMetaMap = [
|
||||
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
|
||||
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
|
||||
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
|
||||
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
|
||||
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
|
||||
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
|
||||
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
] as const;
|
||||
|
||||
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
|
||||
// clone everything
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
// overlay only the dot→under mappings
|
||||
podsMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in raw) {
|
||||
const v = raw[dot];
|
||||
out[under] = typeof v === 'string' ? v : raw[under];
|
||||
}
|
||||
});
|
||||
return out as K8sPodsData['meta'];
|
||||
}
|
||||
|
||||
// getK8sPodsList
|
||||
export const getK8sPodsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sPodsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sPodsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters?.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/pods/list', requestProps, {
|
||||
signal,
|
||||
@@ -1,10 +1,22 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sVolumesListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sVolumesData {
|
||||
persistentVolumeClaimName: string;
|
||||
@@ -37,7 +49,6 @@ export interface K8sVolumesListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const volumesMetaMap: Array<{
|
||||
dot: keyof Record<string, unknown>;
|
||||
under: keyof K8sVolumesData['meta'];
|
||||
@@ -57,8 +68,10 @@ export const volumesMetaMap: Array<{
|
||||
export function mapVolumesMeta(
|
||||
rawMeta: Record<string, unknown>,
|
||||
): K8sVolumesData['meta'] {
|
||||
// start with everything that was already there
|
||||
const out: Record<string, unknown> = { ...rawMeta };
|
||||
|
||||
// for each dot→under rule, if the raw has the dot, overwrite the underscore
|
||||
volumesMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in rawMeta) {
|
||||
const val = rawMeta[dot];
|
||||
@@ -70,47 +83,42 @@ export function mapVolumesMeta(
|
||||
}
|
||||
|
||||
export const getK8sVolumesList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sVolumesListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sVolumesListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const { orderBy, ...rest } = props;
|
||||
const basePayload = {
|
||||
...rest,
|
||||
filters: props.filters ?? { items: [], op: 'and' },
|
||||
...(orderBy != null ? { orderBy } : {}),
|
||||
};
|
||||
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...basePayload,
|
||||
filters: {
|
||||
...basePayload.filters,
|
||||
items: basePayload.filters.items.reduce<typeof basePayload.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
// Prepare filters
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof basePayload.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: basePayload;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/pvcs/list', requestProps, {
|
||||
signal,
|
||||
@@ -1,12 +1,11 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { UnderscoreToDotMap } from '../utils';
|
||||
|
||||
export interface K8sStatefulSetsListPayload {
|
||||
filters: TagFilter;
|
||||
@@ -48,7 +47,6 @@ export interface K8sStatefulSetsListResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const statefulSetsMetaMap = [
|
||||
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
|
||||
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
|
||||
@@ -68,37 +66,42 @@ export function mapStatefulSetsMeta(
|
||||
}
|
||||
|
||||
export const getK8sStatefulSetsList = async (
|
||||
props: K8sBaseFilters,
|
||||
props: K8sStatefulSetsListPayload,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sStatefulSetsListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<TagFilter['items']>((acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, [] as TagFilter['items']),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
// Prepare filters
|
||||
const requestProps =
|
||||
dotMetricsEnabled && Array.isArray(props.filters?.items)
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/statefulsets/list', requestProps, {
|
||||
signal,
|
||||
@@ -106,6 +109,7 @@ export const getK8sStatefulSetsList = async (
|
||||
});
|
||||
const payload: K8sStatefulSetsListResponse = response.data;
|
||||
|
||||
// apply our helper
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapStatefulSetsMeta(record.meta as Record<string, unknown>),
|
||||
88
frontend/src/api/integration/aws/index.ts
Normal file
88
frontend/src/api/integration/aws/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import axios from 'api';
|
||||
import {
|
||||
CloudAccount,
|
||||
Service,
|
||||
ServiceData,
|
||||
UpdateServiceConfigPayload,
|
||||
UpdateServiceConfigResponse,
|
||||
} from 'container/CloudIntegrationPage/ServicesSection/types';
|
||||
import {
|
||||
AccountConfigPayload,
|
||||
AccountConfigResponse,
|
||||
ConnectionParams,
|
||||
ConnectionUrlResponse,
|
||||
} from 'types/api/integrations/aws';
|
||||
|
||||
export const getAwsAccounts = async (): Promise<CloudAccount[]> => {
|
||||
const response = await axios.get('/cloud-integrations/aws/accounts');
|
||||
|
||||
return response.data.data.accounts;
|
||||
};
|
||||
|
||||
export const getAwsServices = async (
|
||||
cloudAccountId?: string,
|
||||
): Promise<Service[]> => {
|
||||
const params = cloudAccountId
|
||||
? { cloud_account_id: cloudAccountId }
|
||||
: undefined;
|
||||
const response = await axios.get('/cloud-integrations/aws/services', {
|
||||
params,
|
||||
});
|
||||
|
||||
return response.data.data.services;
|
||||
};
|
||||
|
||||
export const getServiceDetails = async (
|
||||
serviceId: string,
|
||||
cloudAccountId?: string,
|
||||
): Promise<ServiceData> => {
|
||||
const params = cloudAccountId
|
||||
? { cloud_account_id: cloudAccountId }
|
||||
: undefined;
|
||||
const response = await axios.get(
|
||||
`/cloud-integrations/aws/services/${serviceId}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const generateConnectionUrl = async (params: {
|
||||
agent_config: { region: string };
|
||||
account_config: { regions: string[] };
|
||||
account_id?: string;
|
||||
}): Promise<ConnectionUrlResponse> => {
|
||||
const response = await axios.post(
|
||||
'/cloud-integrations/aws/accounts/generate-connection-url',
|
||||
params,
|
||||
);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateAccountConfig = async (
|
||||
accountId: string,
|
||||
payload: AccountConfigPayload,
|
||||
): Promise<AccountConfigResponse> => {
|
||||
const response = await axios.post<AccountConfigResponse>(
|
||||
`/cloud-integrations/aws/accounts/${accountId}/config`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateServiceConfig = async (
|
||||
serviceId: string,
|
||||
payload: UpdateServiceConfigPayload,
|
||||
): Promise<UpdateServiceConfigResponse> => {
|
||||
const response = await axios.post<UpdateServiceConfigResponse>(
|
||||
`/cloud-integrations/aws/services/${serviceId}/config`,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getConnectionParams = async (): Promise<ConnectionParams> => {
|
||||
const response = await axios.get(
|
||||
'/cloud-integrations/aws/accounts/generate-connection-params',
|
||||
);
|
||||
return response.data.data;
|
||||
};
|
||||
@@ -3,13 +3,16 @@ import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
// 10 min in ms
|
||||
const TIMEOUT_IN_MS = 10 * 60 * 1000;
|
||||
|
||||
export const LiveTail = (queryParams: string): EventSourcePolyfill =>
|
||||
new EventSourcePolyfill(
|
||||
`${ENVIRONMENT.baseURL}${apiV1}logs/tail?${queryParams}`,
|
||||
ENVIRONMENT.baseURL
|
||||
? `${ENVIRONMENT.baseURL}${apiV1}logs/tail?${queryParams}`
|
||||
: withBasePath(`${apiV1}logs/tail?${queryParams}`),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${getLocalStorageKey(LOCALSTORAGE.AUTH_TOKEN)}`,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg width="929" height="8" viewBox="0 0 929 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="dotted-double-line-pattern" x="0" y="0" width="6" height="8" patternUnits="userSpaceOnUse">
|
||||
<rect width="2" height="2" rx="1" fill="#242834" />
|
||||
<rect y="6" width="2" height="2" rx="1" fill="#242834" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="929" height="8" fill="url(#dotted-double-line-pattern)" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 442 B |
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -24,7 +24,6 @@ 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';
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppState } from 'store/reducers';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
export interface NavigateToExplorerProps {
|
||||
filters: TagFilterItem[];
|
||||
@@ -133,7 +134,7 @@ export function useNavigateToExplorer(): (
|
||||
QueryParams.compositeQuery
|
||||
}=${JSONCompositeQuery}`;
|
||||
|
||||
window.open(newExplorerPath, sameTab ? '_self' : '_blank');
|
||||
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
|
||||
},
|
||||
[
|
||||
prepareQuery,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CreditCard, MessageSquareText, X } from 'lucide-react';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
export default function ChatSupportGateway(): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
@@ -54,7 +55,7 @@ export default function ChatSupportGateway(): JSX.Element {
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: window.location.origin,
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
.cloud-service-data-collected {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.cloud-service-data-collected-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.cloud-service-data-collected-table-heading {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
|
||||
/* Bifrost (Ancient)/Content/sm */
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.cloud-service-data-collected-table-logs {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l3-background);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import APIError from 'types/api/error';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import DeleteMemberDialog from './DeleteMemberDialog';
|
||||
@@ -387,7 +388,7 @@ function EditMemberDrawer({
|
||||
pathParams: { id: member.id },
|
||||
});
|
||||
if (response?.data?.token) {
|
||||
const link = `${window.location.origin}/password-reset?token=${response.data.token}`;
|
||||
const link = getAbsoluteUrl(`/password-reset?token=${response.data.token}`);
|
||||
setResetLink(link);
|
||||
setResetLinkExpiresAt(
|
||||
response.data.expiresAt
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
|
||||
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState('feedback');
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button as PeriscopeButton } from '@signozhq/button';
|
||||
import { Tooltip as PeriscopeTooltip } from '@signozhq/tooltip';
|
||||
import { Button, 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 { Globe, Inbox, SquarePen } from 'lucide-react';
|
||||
|
||||
@@ -71,23 +67,9 @@ function HeaderRightSection({
|
||||
};
|
||||
|
||||
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
|
||||
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
|
||||
|
||||
return (
|
||||
<div className="header-right-section-container">
|
||||
{!isDrawerOpen && (
|
||||
<PeriscopeTooltip title="AI Assistant">
|
||||
<PeriscopeButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={openAIAssistant}
|
||||
aria-label="Open AI Assistant"
|
||||
>
|
||||
<AIAssistantIcon size={18} />
|
||||
</PeriscopeButton>
|
||||
</PeriscopeTooltip>
|
||||
)}
|
||||
|
||||
{enableFeedback && isLicenseEnabled && (
|
||||
<Popover
|
||||
rootClassName="header-section-popover-root"
|
||||
|
||||
@@ -13,6 +13,7 @@ import GetMinMax from 'lib/getMinMax';
|
||||
import { Check, Info, Link2 } from 'lucide-react';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
const routesToBeSharedWithTime = [
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
@@ -80,17 +81,13 @@ function ShareURLModal(): JSX.Element {
|
||||
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
|
||||
currentUrl = `${window.location.origin}${
|
||||
location.pathname
|
||||
}?${urlQuery.toString()}`;
|
||||
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.startTime);
|
||||
urlQuery.delete(QueryParams.endTime);
|
||||
|
||||
urlQuery.set(QueryParams.relativeTime, selectedTime);
|
||||
currentUrl = `${window.location.origin}${
|
||||
location.pathname
|
||||
}?${urlQuery.toString()}`;
|
||||
currentUrl = getAbsoluteUrl(`${location.pathname}?${urlQuery.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { toast } from '@signozhq/ui';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
|
||||
import FeedbackModal from '../FeedbackModal';
|
||||
|
||||
@@ -31,7 +31,7 @@ jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/Integrations/utils', () => ({
|
||||
jest.mock('pages/Integrations/utils', () => ({
|
||||
handleContactSupport: jest.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Space, Typography } from 'antd';
|
||||
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
|
||||
|
||||
import 'components/HostMetricsDetail/Containers/Containers.styles.scss';
|
||||
import WaitlistFragment from '../WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import './Containers.styles.scss';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function EntityContainers(): JSX.Element {
|
||||
function Containers(): JSX.Element {
|
||||
const { t } = useTranslation(['infraMonitoring']);
|
||||
|
||||
return (
|
||||
@@ -43,4 +44,4 @@ function EntityContainers(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityContainers;
|
||||
export default Containers;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { HostData } from 'api/infraMonitoring/getHostLists';
|
||||
|
||||
export type HostDetailProps = {
|
||||
host: HostData | null;
|
||||
isModalTimeSelection: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
.host-metric-traces {
|
||||
margin-top: 1rem;
|
||||
|
||||
.host-metric-traces-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.filter-section {
|
||||
flex: 1;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background-color: var(--l3-background) !important;
|
||||
|
||||
input {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-tag .ant-typography {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.host-metric-traces-table {
|
||||
.ant-table-content {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 12px;
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
|
||||
border-bottom: none;
|
||||
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th:has(.hostname-column-header) {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l1-foreground);
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
|
||||
}
|
||||
|
||||
.ant-table-cell:has(.hostname-column-value) {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.hostname-column-value {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.status-cell {
|
||||
.active-tag {
|
||||
color: var(--bg-forest-500);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
.ant-progress-bg {
|
||||
height: 8px !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
|
||||
}
|
||||
|
||||
.ant-table-cell:first-child {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.ant-table-cell:nth-child(2) {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.ant-table-cell:nth-child(n + 3) {
|
||||
padding-right: 24px;
|
||||
}
|
||||
.column-header-right {
|
||||
text-align: right;
|
||||
}
|
||||
.ant-table-tbody > tr > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-table-thead
|
||||
> tr
|
||||
> th:not(:last-child):not(.ant-table-selection-column):not(.ant-table-row-expand-icon-cell):not([colspan])::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ant-empty-normal {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-container::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import { ErrorText } from 'container/TimeSeriesView/styles';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { VIEWS } from '../constants';
|
||||
import { getHostTracesQueryPayload, selectedColumns } from './constants';
|
||||
import { getListColumns } from './utils';
|
||||
|
||||
import './HostMetricTraces.styles.scss';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
handleChangeTracesFilters: (
|
||||
value: IBuilderQuery['filters'],
|
||||
view: VIEWS,
|
||||
) => void;
|
||||
tracesFilters: IBuilderQuery['filters'];
|
||||
selectedInterval: Time;
|
||||
}
|
||||
|
||||
function HostMetricTraces({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
handleChangeTracesFilters,
|
||||
tracesFilters,
|
||||
selectedInterval,
|
||||
}: Props): JSX.Element {
|
||||
const [traces, setTraces] = useState<any[]>([]);
|
||||
const [offset] = useState<number>(0);
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
...currentQuery.builder.queryData[0].aggregateAttribute,
|
||||
},
|
||||
filters: {
|
||||
items:
|
||||
tracesFilters?.items?.filter((item) => item.key?.key !== 'host.name') ||
|
||||
[],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[currentQuery, tracesFilters?.items],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const queryPayload = useMemo(
|
||||
() =>
|
||||
getHostTracesQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
paginationQueryData?.offset || offset,
|
||||
tracesFilters,
|
||||
),
|
||||
[
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
offset,
|
||||
tracesFilters,
|
||||
paginationQueryData,
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useQuery({
|
||||
queryKey: [
|
||||
'hostMetricTraces',
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
offset,
|
||||
tracesFilters,
|
||||
DEFAULT_ENTITY_VERSION,
|
||||
paginationQueryData,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
|
||||
enabled: !!queryPayload,
|
||||
});
|
||||
|
||||
const traceListColumns = getListColumns(selectedColumns);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload?.data?.newResult?.data?.result) {
|
||||
const currentData = data.payload.data.newResult.data.result;
|
||||
if (currentData.length > 0 && currentData[0].list) {
|
||||
if (offset === 0) {
|
||||
setTraces(currentData[0].list ?? []);
|
||||
} else {
|
||||
setTraces((prev) => [...prev, ...(currentData[0].list ?? [])]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [data, offset]);
|
||||
|
||||
const isDataEmpty =
|
||||
!isLoading && !isFetching && !isError && traces.length === 0;
|
||||
const hasAdditionalFilters =
|
||||
tracesFilters?.items && tracesFilters?.items?.length > 1;
|
||||
|
||||
const totalCount =
|
||||
data?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0;
|
||||
|
||||
const handleRowClick = useCallback(() => {
|
||||
logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="host-metric-traces">
|
||||
<div className="host-metric-traces-header">
|
||||
<div className="filter-section">
|
||||
{query && (
|
||||
<QueryBuilderSearch
|
||||
query={query as IBuilderQuery}
|
||||
onChange={(value): void =>
|
||||
handleChangeTracesFilters(value, VIEWS.TRACES)
|
||||
}
|
||||
disableNavigationShortcuts
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="datetime-section">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && <ErrorText>{data?.error || 'Something went wrong'}</ErrorText>}
|
||||
|
||||
{isLoading && traces.length === 0 && <TracesLoading />}
|
||||
|
||||
{isDataEmpty && !hasAdditionalFilters && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataEmpty && hasAdditionalFilters && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
|
||||
{!isError && traces.length > 0 && (
|
||||
<div className="host-metric-traces-table">
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching && traces.length === 0}
|
||||
totalCount={totalCount}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
<ResizeTable
|
||||
tableLayout="fixed"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
loading={isFetching && traces.length === 0}
|
||||
dataSource={traces}
|
||||
columns={traceListColumns}
|
||||
onRow={(): Record<string, unknown> => ({
|
||||
onClick: (): void => handleRowClick(),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMetricTraces;
|
||||
@@ -0,0 +1,183 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { nanoToMilli } from 'utils/timeUtils';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
title: 'Timestamp',
|
||||
width: 200,
|
||||
render: (timestamp: string): string => new Date(timestamp).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: 'Service Name',
|
||||
dataIndex: ['data', 'serviceName'],
|
||||
key: 'serviceName-string-tag',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: ['data', 'name'],
|
||||
key: 'name-string-tag',
|
||||
width: 145,
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
dataIndex: ['data', 'durationNano'],
|
||||
key: 'durationNano-float64-tag',
|
||||
width: 145,
|
||||
render: (duration: number): string => `${nanoToMilli(duration)}ms`,
|
||||
},
|
||||
{
|
||||
title: 'HTTP Method',
|
||||
dataIndex: ['data', 'httpMethod'],
|
||||
key: 'httpMethod-string-tag',
|
||||
width: 145,
|
||||
},
|
||||
{
|
||||
title: 'Status Code',
|
||||
dataIndex: ['data', 'responseStatusCode'],
|
||||
key: 'responseStatusCode-string-tag',
|
||||
width: 145,
|
||||
},
|
||||
];
|
||||
|
||||
export const selectedColumns: BaseAutocompleteData[] = [
|
||||
{
|
||||
key: 'timestamp',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'serviceName',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'durationNano',
|
||||
dataType: DataTypes.Float64,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'httpMethod',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'responseStatusCode',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
];
|
||||
|
||||
export const getHostTracesQueryPayload = (
|
||||
start: number,
|
||||
end: number,
|
||||
offset = 0,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): GetQueryResultsProps => ({
|
||||
query: {
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.EMPTY,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters,
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: '572f1d91-6ac0-46c0-b726-c21488b34434',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
start,
|
||||
end,
|
||||
params: {
|
||||
dataSource: DataSource.TRACES,
|
||||
},
|
||||
tableParams: {
|
||||
pagination: {
|
||||
limit: 10,
|
||||
offset,
|
||||
},
|
||||
selectColumns: [
|
||||
{
|
||||
key: 'serviceName',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'serviceName--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'name--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
{
|
||||
key: 'durationNano',
|
||||
dataType: 'float64',
|
||||
type: 'tag',
|
||||
id: 'durationNano--float64--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
{
|
||||
key: 'httpMethod',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'httpMethod--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
{
|
||||
key: 'responseStatusCode',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'responseStatusCode--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -18,7 +18,7 @@ const keyToLabelMap: Record<string, string> = {
|
||||
responseStatusCode: 'Status Code',
|
||||
};
|
||||
|
||||
export const getTraceListColumns = (
|
||||
export const getListColumns = (
|
||||
selectedColumns: BaseAutocompleteData[],
|
||||
): ColumnsType<RowData> => {
|
||||
const columns: ColumnsType<RowData> =
|
||||
@@ -0,0 +1,176 @@
|
||||
.host-detail-drawer {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-drawer-header {
|
||||
padding: 8px 16px;
|
||||
border-bottom: none;
|
||||
|
||||
align-items: stretch;
|
||||
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.radio-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: var(--padding-1);
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.host-detail-drawer__host {
|
||||
.host-details-grid {
|
||||
.labels-row,
|
||||
.values-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr 1.5fr 1.5fr;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.labels-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.host-details-metadata-label {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
margin: 0;
|
||||
|
||||
&.active {
|
||||
color: var(--success-500);
|
||||
background: var(--success-100);
|
||||
border-color: var(--success-500);
|
||||
}
|
||||
|
||||
&.inactive {
|
||||
color: var(--error-500);
|
||||
background: var(--error-100);
|
||||
border-color: var(--error-500);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 158px;
|
||||
.ant-progress {
|
||||
margin: 0;
|
||||
|
||||
.ant-progress-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
&.ant-card-bordered {
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-and-search {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 16px 0;
|
||||
|
||||
.action-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.views-tabs-container {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.views-tabs {
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.view-title {
|
||||
display: flex;
|
||||
gap: var(--margin-2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xs);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 1px solid var(--l1-border);
|
||||
width: 114px;
|
||||
}
|
||||
|
||||
.tab::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.selected_view {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.selected_view::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.compass-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
590
frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx
Normal file
590
frontend/src/components/HostMetricsDetail/HostMetricsDetails.tsx
Normal file
@@ -0,0 +1,590 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
Progress,
|
||||
Radio,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { RadioChangeEvent } from 'antd/lib';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getFiltersFromParams } from 'container/InfraMonitoringK8s/commonUtils';
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from 'container/InfraMonitoringK8s/constants';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import {
|
||||
BarChart2,
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
Package2,
|
||||
ScrollText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
LogsAggregatorOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { VIEW_TYPES, VIEWS } from './constants';
|
||||
import Containers from './Containers/Containers';
|
||||
import { HostDetailProps } from './HostMetricDetail.interfaces';
|
||||
import HostMetricLogsDetailedView from './HostMetricsLogs/HostMetricLogsDetailedView';
|
||||
import HostMetricTraces from './HostMetricTraces/HostMetricTraces';
|
||||
import Metrics from './Metrics/Metrics';
|
||||
import Processes from './Processes/Processes';
|
||||
|
||||
import './HostMetricsDetail.styles.scss';
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function HostMetricsDetails({
|
||||
host,
|
||||
onClose,
|
||||
isModalTimeSelection,
|
||||
}: HostDetailProps): JSX.Element {
|
||||
const { maxTime, minTime, selectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
|
||||
minTime,
|
||||
]);
|
||||
const endMs = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [
|
||||
maxTime,
|
||||
]);
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
endTime: endMs,
|
||||
}));
|
||||
|
||||
const lastSelectedInterval = useRef<Time | null>(null);
|
||||
|
||||
const [selectedInterval, setSelectedInterval] = useState<Time>(
|
||||
lastSelectedInterval.current
|
||||
? lastSelectedInterval.current
|
||||
: (selectedTime as Time),
|
||||
);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<VIEWS>(
|
||||
(searchParams.get('view') as VIEWS) || VIEWS.METRICS,
|
||||
);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const initialFilters = useMemo(() => {
|
||||
const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW);
|
||||
const queryKey =
|
||||
urlView === VIEW_TYPES.LOGS
|
||||
? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS
|
||||
: INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS;
|
||||
const filters = getFiltersFromParams(searchParams, queryKey);
|
||||
if (filters) {
|
||||
return filters;
|
||||
}
|
||||
|
||||
return {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
key: 'host.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: 'host.name--string--resource--false',
|
||||
},
|
||||
op: '=',
|
||||
value: host?.hostName || '',
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [host?.hostName, searchParams]);
|
||||
|
||||
const [logFilters, setLogFilters] = useState<IBuilderQuery['filters']>(
|
||||
initialFilters,
|
||||
);
|
||||
|
||||
const [tracesFilters, setTracesFilters] = useState<IBuilderQuery['filters']>(
|
||||
initialFilters,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (host) {
|
||||
logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
}
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
setLogFilters(initialFilters);
|
||||
setTracesFilters(initialFilters);
|
||||
}, [initialFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
|
||||
setSelectedInterval(currentSelectedInterval as Time);
|
||||
|
||||
if (currentSelectedInterval !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(currentSelectedInterval);
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / 1000000000),
|
||||
endTime: Math.floor(maxTime / 1000000000),
|
||||
});
|
||||
}
|
||||
}, [selectedTime, minTime, maxTime]);
|
||||
|
||||
const handleTabChange = (e: RadioChangeEvent): void => {
|
||||
setSelectedView(e.target.value);
|
||||
if (host?.hostName) {
|
||||
setSelectedView(e.target.value);
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null),
|
||||
});
|
||||
}
|
||||
logEvent(InfraMonitoringEvents.TabChanged, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
lastSelectedInterval.current = interval as Time;
|
||||
setSelectedInterval(interval as Time);
|
||||
|
||||
if (interval === 'custom' && dateTimeRange) {
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / 1000000000),
|
||||
endTime: Math.floor(maxTime / 1000000000),
|
||||
});
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.TimeUpdated, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
interval,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleChangeLogFilters = useCallback(
|
||||
(value: IBuilderQuery['filters'], view: VIEWS) => {
|
||||
setLogFilters((prevFilters) => {
|
||||
const hostNameFilter = prevFilters?.items?.find(
|
||||
(item) => item.key?.key === 'host.name',
|
||||
);
|
||||
const paginationFilter = value?.items?.find(
|
||||
(item) => item.key?.key === 'id',
|
||||
);
|
||||
const newFilters = value?.items?.filter(
|
||||
(item) => item.key?.key !== 'id' && item.key?.key !== 'host.name',
|
||||
);
|
||||
|
||||
if (newFilters && newFilters?.length > 0) {
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.LogsView,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedFilters = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
hostNameFilter,
|
||||
...(newFilters || []),
|
||||
...(paginationFilter ? [paginationFilter] : []),
|
||||
].filter((item): item is TagFilterItem => item !== undefined),
|
||||
};
|
||||
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(
|
||||
updatedFilters,
|
||||
),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
|
||||
});
|
||||
return updatedFilters;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const handleChangeTracesFilters = useCallback(
|
||||
(value: IBuilderQuery['filters'], view: VIEWS) => {
|
||||
setTracesFilters((prevFilters) => {
|
||||
const hostNameFilter = prevFilters?.items?.find(
|
||||
(item) => item.key?.key === 'host.name',
|
||||
);
|
||||
|
||||
if (value?.items && value?.items?.length > 0) {
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
view: InfraMonitoringEvents.TracesView,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedFilters = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
hostNameFilter,
|
||||
...(value?.items?.filter((item) => item.key?.key !== 'host.name') || []),
|
||||
].filter((item): item is TagFilterItem => item !== undefined),
|
||||
};
|
||||
|
||||
setSearchParams({
|
||||
...Object.fromEntries(searchParams.entries()),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(
|
||||
updatedFilters,
|
||||
),
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
|
||||
});
|
||||
|
||||
return updatedFilters;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
const handleExplorePagesRedirect = (): void => {
|
||||
if (selectedInterval !== 'custom') {
|
||||
urlQuery.set(QueryParams.relativeTime, selectedInterval);
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.ExploreClicked, {
|
||||
view: selectedView,
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const filtersWithoutPagination = {
|
||||
...logFilters,
|
||||
items: logFilters?.items?.filter((item) => item.key?.key !== 'id') || [],
|
||||
};
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
filters: filtersWithoutPagination,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
filters: tracesFilters,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
setSelectedInterval(selectedTime as Time);
|
||||
lastSelectedInterval.current = null;
|
||||
setSearchParams({});
|
||||
|
||||
if (selectedTime !== 'custom') {
|
||||
const { maxTime, minTime } = GetMinMax(selectedTime);
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / 1000000000),
|
||||
endTime: Math.floor(maxTime / 1000000000),
|
||||
});
|
||||
}
|
||||
setSelectedView(VIEW_TYPES.METRICS);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="70%"
|
||||
title={
|
||||
<>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text className="title">{host?.hostName}</Typography.Text>
|
||||
</>
|
||||
}
|
||||
placement="right"
|
||||
onClose={handleClose}
|
||||
open={!!host}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
|
||||
}}
|
||||
className="host-detail-drawer"
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
{host && (
|
||||
<>
|
||||
<div className="host-detail-drawer__host">
|
||||
<div className="host-details-grid">
|
||||
<div className="labels-row">
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
className="host-details-metadata-label"
|
||||
>
|
||||
STATUS
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
className="host-details-metadata-label"
|
||||
>
|
||||
OPERATING SYSTEM
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
className="host-details-metadata-label"
|
||||
>
|
||||
CPU USAGE
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
className="host-details-metadata-label"
|
||||
>
|
||||
MEMORY USAGE
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="values-row">
|
||||
<Tag
|
||||
bordered
|
||||
className={`infra-monitoring-tags ${
|
||||
host.active ? 'active' : 'inactive'
|
||||
}`}
|
||||
>
|
||||
{host.active ? 'ACTIVE' : 'INACTIVE'}
|
||||
</Tag>
|
||||
{host.os ? (
|
||||
<Tag className="infra-monitoring-tags" bordered>
|
||||
{host.os}
|
||||
</Tag>
|
||||
) : (
|
||||
<Typography.Text>-</Typography.Text>
|
||||
)}
|
||||
<div className="progress-container">
|
||||
<Progress
|
||||
percent={Number((host.cpu * 100).toFixed(1))}
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const cpuPercent = Number((host.cpu * 100).toFixed(1));
|
||||
if (cpuPercent >= 90) {
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
if (cpuPercent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
className="progress-bar"
|
||||
/>
|
||||
</div>
|
||||
<div className="progress-container">
|
||||
<Progress
|
||||
percent={Number((host.memory * 100).toFixed(1))}
|
||||
size="small"
|
||||
strokeColor={((): string => {
|
||||
const memoryPercent = Number((host.memory * 100).toFixed(1));
|
||||
if (memoryPercent >= 90) {
|
||||
return Color.BG_CHERRY_500;
|
||||
}
|
||||
if (memoryPercent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
})()}
|
||||
className="progress-bar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="views-tabs-container">
|
||||
<Radio.Group
|
||||
className="views-tabs"
|
||||
onChange={handleTabChange}
|
||||
value={selectedView}
|
||||
>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.METRICS ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.METRICS}
|
||||
>
|
||||
<div className="view-title">
|
||||
<BarChart2 size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.LOGS ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.LOGS}
|
||||
>
|
||||
<div className="view-title">
|
||||
<ScrollText size={14} />
|
||||
Logs
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.TRACES ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.TRACES}
|
||||
>
|
||||
<div className="view-title">
|
||||
<DraftingCompass size={14} />
|
||||
Traces
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.CONTAINERS ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.CONTAINERS}
|
||||
>
|
||||
<div className="view-title">
|
||||
<Package2 size={14} />
|
||||
Containers
|
||||
</div>
|
||||
</Radio.Button>
|
||||
<Radio.Button
|
||||
className={
|
||||
selectedView === VIEW_TYPES.PROCESSES ? 'selected_view tab' : 'tab'
|
||||
}
|
||||
value={VIEW_TYPES.PROCESSES}
|
||||
>
|
||||
<div className="view-title">
|
||||
<ChevronsLeftRight size={14} />
|
||||
Processes
|
||||
</div>
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{(selectedView === VIEW_TYPES.LOGS ||
|
||||
selectedView === VIEW_TYPES.TRACES) && (
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedView === VIEW_TYPES.METRICS && (
|
||||
<Metrics
|
||||
selectedInterval={selectedInterval}
|
||||
hostName={host.hostName}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
<HostMetricLogsDetailedView
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
handleTimeChange={handleTimeChange}
|
||||
handleChangeLogFilters={handleChangeLogFilters}
|
||||
logFilters={logFilters}
|
||||
selectedInterval={selectedInterval}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
<HostMetricTraces
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
handleTimeChange={handleTimeChange}
|
||||
handleChangeTracesFilters={handleChangeTracesFilters}
|
||||
tracesFilters={tracesFilters}
|
||||
selectedInterval={selectedInterval}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.CONTAINERS && <Containers />}
|
||||
{selectedView === VIEW_TYPES.PROCESSES && <Processes />}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMetricsDetails;
|
||||
@@ -0,0 +1,119 @@
|
||||
.host-metrics-logs-container {
|
||||
margin-top: 1rem;
|
||||
|
||||
.filter-section {
|
||||
flex: 1;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
|
||||
input {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-tag .ant-typography {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.host-metrics-logs-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.host-metrics-logs {
|
||||
margin-top: 1rem;
|
||||
|
||||
.virtuoso-list {
|
||||
overflow-y: hidden !important;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-row {
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-container {
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.host-metrics-logs-list-container {
|
||||
flex: 1;
|
||||
height: calc(100vh - 272px) !important;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.raw-log-content {
|
||||
width: 100%;
|
||||
text-wrap: inherit;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.host-metrics-logs-list-card {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.logs-loading-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
.ant-skeleton-input-sm {
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-logs-found {
|
||||
height: 50vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo } from 'react';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { VIEWS } from '../constants';
|
||||
import HostMetricsLogs from './HostMetricsLogs';
|
||||
|
||||
import './HostMetricLogs.styles.scss';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
handleChangeLogFilters: (value: IBuilderQuery['filters'], view: VIEWS) => void;
|
||||
logFilters: IBuilderQuery['filters'];
|
||||
selectedInterval: Time;
|
||||
}
|
||||
|
||||
function HostMetricLogsDetailedView({
|
||||
timeRange,
|
||||
isModalTimeSelection,
|
||||
handleTimeChange,
|
||||
handleChangeLogFilters,
|
||||
logFilters,
|
||||
selectedInterval,
|
||||
}: Props): JSX.Element {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
...currentQuery.builder.queryData[0].aggregateAttribute,
|
||||
},
|
||||
filters: {
|
||||
items:
|
||||
logFilters?.items?.filter((item) => item.key?.key !== 'host.name') ||
|
||||
[],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
[currentQuery, logFilters?.items],
|
||||
);
|
||||
|
||||
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
|
||||
|
||||
return (
|
||||
<div className="host-metrics-logs-container">
|
||||
<div className="host-metrics-logs-header">
|
||||
<div className="filter-section">
|
||||
{query && (
|
||||
<QueryBuilderSearch
|
||||
query={query as IBuilderQuery}
|
||||
onChange={(value): void => handleChangeLogFilters(value, VIEWS.LOGS)}
|
||||
disableNavigationShortcuts
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="datetime-section">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HostMetricsLogs timeRange={timeRange} filters={logFilters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMetricLogsDetailedView;
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Card } from 'antd';
|
||||
import LogDetail from 'components/LogDetail';
|
||||
import RawLogView from 'components/Logs/RawLogView';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getHostLogsQueryPayload } from './constants';
|
||||
import NoLogsContainer from './NoLogsContainer';
|
||||
|
||||
import './HostMetricLogs.styles.scss';
|
||||
|
||||
interface Props {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
filters: IBuilderQuery['filters'];
|
||||
}
|
||||
|
||||
function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
selectedTab,
|
||||
handleSetActiveLog,
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
const basePayload = getHostLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
filters,
|
||||
);
|
||||
const {
|
||||
logs,
|
||||
hasReachedEndOfLogs,
|
||||
isPaginating,
|
||||
currentPage,
|
||||
setIsPaginating,
|
||||
handleNewData,
|
||||
loadMoreLogs,
|
||||
queryPayload,
|
||||
} = useHandleLogsPagination({
|
||||
timeRange,
|
||||
filters,
|
||||
excludeFilterKeys: ['host.name'],
|
||||
basePayload,
|
||||
});
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useQuery({
|
||||
queryKey: [
|
||||
'hostMetricsLogs',
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
filters,
|
||||
currentPage,
|
||||
],
|
||||
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
|
||||
enabled: !!queryPayload,
|
||||
keepPreviousData: isPaginating,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload?.data?.newResult?.data?.result) {
|
||||
handleNewData(data.payload.data.newResult.data.result);
|
||||
}
|
||||
}, [data, handleNewData]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsPaginating(false);
|
||||
}, [data, setIsPaginating]);
|
||||
|
||||
const handleScrollToLog = useScrollToLog({
|
||||
logs,
|
||||
virtuosoRef,
|
||||
});
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, logToRender: ILog): JSX.Element => {
|
||||
return (
|
||||
<div key={logToRender.id}>
|
||||
<RawLogView
|
||||
isTextOverflowEllipsisDisabled
|
||||
data={logToRender}
|
||||
linesPerRow={5}
|
||||
fontSize={FontSize.MEDIUM}
|
||||
selectedFields={[
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'body',
|
||||
},
|
||||
{
|
||||
dataType: 'string',
|
||||
type: '',
|
||||
name: 'timestamp',
|
||||
},
|
||||
]}
|
||||
onSetActiveLog={handleSetActiveLog}
|
||||
onClearActiveLog={handleCloseLogDetail}
|
||||
isActiveLog={activeLog?.id === logToRender.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[activeLog, handleSetActiveLog, handleCloseLogDetail],
|
||||
);
|
||||
|
||||
const renderFooter = useCallback(
|
||||
(): JSX.Element | null => (
|
||||
<>
|
||||
{isFetching ? (
|
||||
<div className="logs-loading-skeleton"> Loading more logs ... </div>
|
||||
) : hasReachedEndOfLogs ? (
|
||||
<div className="logs-loading-skeleton"> *** End *** </div>
|
||||
) : null}
|
||||
</>
|
||||
),
|
||||
[isFetching, hasReachedEndOfLogs],
|
||||
);
|
||||
|
||||
const renderContent = useMemo(
|
||||
() => (
|
||||
<Card bordered={false} className="host-metrics-logs-list-card">
|
||||
<OverlayScrollbar isVirtuoso>
|
||||
<Virtuoso
|
||||
className="host-metrics-logs-virtuoso"
|
||||
key="host-metrics-logs-virtuoso"
|
||||
ref={virtuosoRef}
|
||||
data={logs}
|
||||
endReached={loadMoreLogs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
overscan={200}
|
||||
components={{
|
||||
Footer: renderFooter,
|
||||
}}
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</Card>
|
||||
),
|
||||
[logs, loadMoreLogs, getItemContent, renderFooter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="host-metrics-logs">
|
||||
{isLoading && <LogsLoading />}
|
||||
{!isLoading && !isError && logs.length === 0 && <NoLogsContainer />}
|
||||
{isError && !isLoading && <LogsError />}
|
||||
{!isLoading && !isError && logs.length > 0 && (
|
||||
<div
|
||||
className="host-metrics-logs-list-container"
|
||||
data-log-detail-ignore="true"
|
||||
>
|
||||
{renderContent}
|
||||
</div>
|
||||
)}
|
||||
{selectedTab && activeLog && (
|
||||
<LogDetail
|
||||
log={activeLog}
|
||||
onClose={handleCloseLogDetail}
|
||||
logs={logs}
|
||||
onNavigateLog={handleSetActiveLog}
|
||||
selectedTab={selectedTab}
|
||||
onAddToQuery={onAddToQuery}
|
||||
onClickActionItem={onAddToQuery}
|
||||
onScrollToLog={handleScrollToLog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HostMetricsLogs;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Typography } from 'antd';
|
||||
import { Ghost } from 'lucide-react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function NoLogsContainer(): React.ReactElement {
|
||||
return (
|
||||
<div className="no-logs-found">
|
||||
<Text type="secondary">
|
||||
<Ghost size={24} color={Color.BG_AMBER_500} /> No logs found for this host
|
||||
in the selected time range.
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const getHostLogsQueryPayload = (
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
): GetQueryResultsProps => ({
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
query: {
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters,
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
offset: 0,
|
||||
pageSize: 100,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
id: uuidv4(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
start,
|
||||
end,
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
.empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.host-metrics-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.metrics-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.host-metrics-card {
|
||||
margin: 8px 0 1rem 0;
|
||||
height: 300px;
|
||||
padding: 10px;
|
||||
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
.ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-data-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
233
frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx
Normal file
233
frontend/src/components/HostMetricsDetail/Metrics/Metrics.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { QueryFunctionContext, useQueries, UseQueryResult } from 'react-query';
|
||||
import { Card, Col, Row, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import {
|
||||
getHostQueryPayload,
|
||||
hostWidgetInfo,
|
||||
} from 'container/LogDetailedView/InfraMetrics/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { FeatureKeys } from '../../../constants/features';
|
||||
import { useAppContext } from '../../../providers/App/App';
|
||||
|
||||
import './Metrics.styles.scss';
|
||||
|
||||
interface MetricsTabProps {
|
||||
timeRange: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isModalTimeSelection: boolean;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
selectedInterval: Time;
|
||||
|
||||
hostName: string;
|
||||
}
|
||||
|
||||
function Metrics({
|
||||
selectedInterval,
|
||||
hostName,
|
||||
timeRange,
|
||||
handleTimeChange,
|
||||
isModalTimeSelection,
|
||||
}: MetricsTabProps): JSX.Element {
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const {
|
||||
visibilities,
|
||||
setElement,
|
||||
} = useMultiIntersectionObserver(hostWidgetInfo.length, { threshold: 0.1 });
|
||||
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
() =>
|
||||
getHostQueryPayload(
|
||||
hostName,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
dotMetricsEnabled,
|
||||
),
|
||||
[hostName, timeRange.startTime, timeRange.endTime, dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const queries = useQueries(
|
||||
queryPayloads.map((payload, index) => ({
|
||||
queryKey: ['host-metrics', payload, ENTITY_VERSION_V4, 'HOST'],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: QueryFunctionContext): Promise<
|
||||
SuccessResponse<MetricRangePayloadProps>
|
||||
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
|
||||
enabled: !!payload && visibilities[index],
|
||||
keepPreviousData: true,
|
||||
})),
|
||||
);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const chartData = useMemo(
|
||||
() => queries.map(({ data }) => getUPlotChartData(data?.payload)),
|
||||
[queries],
|
||||
);
|
||||
|
||||
const [graphTimeIntervals, setGraphTimeIntervals] = useState<
|
||||
{
|
||||
start: number;
|
||||
end: number;
|
||||
}[]
|
||||
>(
|
||||
new Array(queries.length).fill({
|
||||
start: timeRange.startTime,
|
||||
end: timeRange.endTime,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setGraphTimeIntervals(
|
||||
new Array(queries.length).fill({
|
||||
start: timeRange.startTime,
|
||||
end: timeRange.endTime,
|
||||
}),
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeRange]);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number, graphIndex: number) => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
setGraphTimeIntervals((prev) => {
|
||||
const newIntervals = [...prev];
|
||||
newIntervals[graphIndex] = {
|
||||
start: Math.floor(startTimestamp / 1000),
|
||||
end: Math.floor(endTimestamp / 1000),
|
||||
};
|
||||
return newIntervals;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
queries.map(({ data }, idx) =>
|
||||
getUPlotChartOptions({
|
||||
apiResponse: data?.payload,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
yAxisUnit: hostWidgetInfo[idx].yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
minTimeScale: graphTimeIntervals[idx].start,
|
||||
maxTimeScale: graphTimeIntervals[idx].end,
|
||||
onDragSelect: (start, end) => onDragSelect(start, end, idx),
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
),
|
||||
[
|
||||
queries,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
graphTimeIntervals,
|
||||
onDragSelect,
|
||||
currentQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const renderCardContent = (
|
||||
query: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>,
|
||||
idx: number,
|
||||
): JSX.Element => {
|
||||
if ((!query.data && query.isLoading) || !visibilities[idx]) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
if (query.error) {
|
||||
const errorMessage =
|
||||
(query.error as Error)?.message || 'Something went wrong';
|
||||
return <div>{errorMessage}</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cx('chart-container', {
|
||||
'no-data-container':
|
||||
!query.isLoading && !query?.data?.payload?.data?.result?.length,
|
||||
})}
|
||||
>
|
||||
<Uplot options={options[idx]} data={chartData[idx]} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="metrics-header">
|
||||
<div className="metrics-datetime-section">
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
onTimeChange={handleTimeChange}
|
||||
defaultRelativeTime="5m"
|
||||
isModalTimeSelection={isModalTimeSelection}
|
||||
modalSelectedInterval={selectedInterval}
|
||||
modalInitialStartTime={timeRange.startTime * 1000}
|
||||
modalInitialEndTime={timeRange.endTime * 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={24} className="host-metrics-container">
|
||||
{queries.map((query, idx) => (
|
||||
<Col ref={setElement(idx)} span={12} key={hostWidgetInfo[idx].title}>
|
||||
<Typography.Text>{hostWidgetInfo[idx].title}</Typography.Text>
|
||||
<Card bordered className="host-metrics-card" ref={graphRef}>
|
||||
{renderCardContent(query, idx)}
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Metrics;
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Space, Typography } from 'antd';
|
||||
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
|
||||
|
||||
import 'components/HostMetricsDetail/Processes/Processes.styles.scss';
|
||||
import WaitlistFragment from '../WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import './Processes.styles.scss';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function EntityProcesses(): JSX.Element {
|
||||
function Processes(): JSX.Element {
|
||||
const { t } = useTranslation(['infraMonitoring']);
|
||||
|
||||
return (
|
||||
@@ -42,4 +43,4 @@ function EntityProcesses(): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityProcesses;
|
||||
export default Processes;
|
||||
17
frontend/src/components/HostMetricsDetail/constants.ts
Normal file
17
frontend/src/components/HostMetricsDetail/constants.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export enum VIEWS {
|
||||
METRICS = 'metrics',
|
||||
LOGS = 'logs',
|
||||
TRACES = 'traces',
|
||||
CONTAINERS = 'containers',
|
||||
PROCESSES = 'processes',
|
||||
EVENTS = 'events',
|
||||
}
|
||||
|
||||
export const VIEW_TYPES = {
|
||||
METRICS: VIEWS.METRICS,
|
||||
LOGS: VIEWS.LOGS,
|
||||
TRACES: VIEWS.TRACES,
|
||||
CONTAINERS: VIEWS.CONTAINERS,
|
||||
PROCESSES: VIEWS.PROCESSES,
|
||||
EVENTS: VIEWS.EVENTS,
|
||||
};
|
||||
3
frontend/src/components/HostMetricsDetail/index.tsx
Normal file
3
frontend/src/components/HostMetricsDetail/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import HostMetricsDetails from './HostMetricsDetails';
|
||||
|
||||
export default HostMetricsDetails;
|
||||
@@ -14,6 +14,7 @@ import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { ROLES } from 'types/roles';
|
||||
import { EMAIL_REGEX } from 'utils/app';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -188,7 +189,7 @@ function InviteMembersModal({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role as ROLES,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
});
|
||||
} else {
|
||||
await inviteUsers({
|
||||
@@ -196,7 +197,7 @@ function InviteMembersModal({
|
||||
email: row.email.trim(),
|
||||
name: '',
|
||||
role: row.role,
|
||||
frontendBaseUrl: window.location.origin,
|
||||
frontendBaseUrl: getBaseUrl(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import APIError from 'types/api/error';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import './LaunchChatSupport.styles.scss';
|
||||
|
||||
@@ -154,7 +155,7 @@ function LaunchChatSupport({
|
||||
});
|
||||
|
||||
updateCreditCard({
|
||||
url: window.location.origin,
|
||||
url: getBaseUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from 'antd';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import './LearnMore.styles.scss';
|
||||
|
||||
@@ -14,7 +15,7 @@ function LearnMore({ text, url, onClick }: LearnMoreProps): JSX.Element {
|
||||
const handleClick = (): void => {
|
||||
onClick?.();
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
openInNewTab(url);
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -100,19 +100,16 @@ function MarkdownRenderer({
|
||||
variables,
|
||||
trackCopyAction,
|
||||
elementDetails,
|
||||
className,
|
||||
}: {
|
||||
markdownContent: any;
|
||||
variables: any;
|
||||
trackCopyAction?: boolean;
|
||||
elementDetails?: Record<string, unknown>;
|
||||
className?: string;
|
||||
}): JSX.Element {
|
||||
const interpolatedMarkdown = interpolateMarkdown(markdownContent, variables);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={className}
|
||||
rehypePlugins={[rehypeRaw as any]}
|
||||
components={{
|
||||
// @ts-ignore
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
KAFKA_SETUP_DOC_LINK,
|
||||
MessagingQueueHealthCheckService,
|
||||
} from 'pages/MessagingQueues/MessagingQueuesUtils';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './MessagingQueueHealthCheck.styles.scss';
|
||||
@@ -76,7 +77,7 @@ function ErrorTitleAndKey({
|
||||
if (isCloudUserVal && !!link) {
|
||||
history.push(link);
|
||||
} else {
|
||||
window.open(KAFKA_SETUP_DOC_LINK, '_blank');
|
||||
openInNewTab(KAFKA_SETUP_DOC_LINK);
|
||||
}
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import removeSessionStorageApi from 'api/browser/sessionstorage/remove';
|
||||
import setSessionStorageApi from 'api/browser/sessionstorage/set';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const PREVIOUS_QUERY_KEY = 'previousQuery';
|
||||
|
||||
function getPreviousQueryFromStore(): Record<string, IBuilderQuery> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PREVIOUS_QUERY_KEY);
|
||||
const raw = getSessionStorageApi(PREVIOUS_QUERY_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
@@ -17,7 +20,7 @@ function getPreviousQueryFromStore(): Record<string, IBuilderQuery> {
|
||||
|
||||
function writePreviousQueryToStore(store: Record<string, IBuilderQuery>): void {
|
||||
try {
|
||||
sessionStorage.setItem(PREVIOUS_QUERY_KEY, JSON.stringify(store));
|
||||
setSessionStorageApi(PREVIOUS_QUERY_KEY, JSON.stringify(store));
|
||||
} catch {
|
||||
// ignore quota or serialization errors
|
||||
}
|
||||
@@ -63,7 +66,7 @@ export const removeKeyFromPreviousQuery = (key: string): void => {
|
||||
|
||||
export const clearPreviousQuery = (): void => {
|
||||
try {
|
||||
sessionStorage.removeItem(PREVIOUS_QUERY_KEY);
|
||||
removeSessionStorageApi(PREVIOUS_QUERY_KEY);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
|
||||
import { DynamicColumnsKey } from './contants';
|
||||
import {
|
||||
GetNewColumnDataFunction,
|
||||
@@ -12,7 +15,7 @@ export const getVisibleColumns: GetVisibleColumnsFunction = ({
|
||||
}) => {
|
||||
let columnVisibilityData: { [key: string]: boolean };
|
||||
try {
|
||||
const storedData = localStorage.getItem(tablesource);
|
||||
const storedData = getLocalStorageKey(tablesource);
|
||||
if (typeof storedData === 'string' && dynamicColumns) {
|
||||
columnVisibilityData = JSON.parse(storedData);
|
||||
return dynamicColumns.filter((column) => {
|
||||
@@ -28,7 +31,7 @@ export const getVisibleColumns: GetVisibleColumnsFunction = ({
|
||||
initialColumnVisibility[key] = false;
|
||||
});
|
||||
|
||||
localStorage.setItem(tablesource, JSON.stringify(initialColumnVisibility));
|
||||
setLocalStorageKey(tablesource, JSON.stringify(initialColumnVisibility));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -42,14 +45,14 @@ export const setVisibleColumns = ({
|
||||
dynamicColumns,
|
||||
}: SetVisibleColumnsProps): void => {
|
||||
try {
|
||||
const storedData = localStorage.getItem(tablesource);
|
||||
const storedData = getLocalStorageKey(tablesource);
|
||||
if (typeof storedData === 'string' && dynamicColumns) {
|
||||
const columnVisibilityData = JSON.parse(storedData);
|
||||
const { key } = dynamicColumns[index];
|
||||
if (key) {
|
||||
columnVisibilityData[key] = checked;
|
||||
}
|
||||
localStorage.setItem(tablesource, JSON.stringify(columnVisibilityData));
|
||||
setLocalStorageKey(tablesource, JSON.stringify(columnVisibilityData));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -7,9 +7,7 @@ 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 />}>
|
||||
<div />
|
||||
</Spin>
|
||||
<Spin spinning size={size} tip={tip} indicator={<LoadingOutlined spin />} />
|
||||
</SpinerStyle>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
/* Override the background and color of the dialog content from the theme */
|
||||
> div {
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.cmdk-section-heading [cmdk-group-heading] {
|
||||
@@ -51,22 +43,6 @@
|
||||
|
||||
.cmdk-item {
|
||||
cursor: pointer;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background-hover);
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
[cmdk-item] svg {
|
||||
|
||||
@@ -65,7 +65,6 @@ const ROUTES = {
|
||||
WORKSPACE_SUSPENDED: '/workspace-suspended',
|
||||
SHORTCUTS: '/settings/shortcuts',
|
||||
INTEGRATIONS: '/integrations',
|
||||
INTEGRATIONS_DETAIL: '/integrations/:integrationId',
|
||||
MESSAGING_QUEUES_BASE: '/messaging-queues',
|
||||
MESSAGING_QUEUES_KAFKA: '/messaging-queues/kafka',
|
||||
MESSAGING_QUEUES_KAFKA_DETAIL: '/messaging-queues/kafka/detail',
|
||||
@@ -87,8 +86,6 @@ 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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { 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="xs"
|
||||
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="xs"
|
||||
onClick={handleClear}
|
||||
disabled={!activeConversationId || showHistory}
|
||||
aria-label="Clear chat"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Minimize to side panel">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleMinimize}
|
||||
aria-label="Minimize to side panel"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { matchPath, useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { 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="xs"
|
||||
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="xs"
|
||||
onClick={handleClear}
|
||||
disabled={!activeConversationId || showHistory}
|
||||
aria-label="Clear chat"
|
||||
>
|
||||
<Eraser size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="New conversation">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleNew}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Open full screen">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={handleExpand}
|
||||
disabled={!activeConversationId}
|
||||
aria-label="Open full screen"
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Close">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={closeDrawer}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistory ? (
|
||||
<HistorySidebar onSelect={handleHistorySelect} />
|
||||
) : (
|
||||
activeConversationId && (
|
||||
<ConversationView conversationId={activeConversationId} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,649 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,911 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
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="xs"
|
||||
onClick={handleApprove}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
<Check size={12} />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="xs"
|
||||
onClick={handleReject}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
<X size={12} />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { 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="xs"
|
||||
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="xs"
|
||||
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="xs"
|
||||
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="xs"
|
||||
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="xs"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
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="xs"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { 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="xs"
|
||||
className="ai-history__item-btn"
|
||||
onClick={startEditing}
|
||||
aria-label="Rename conversation"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ai-history__item-btn ai-history__item-btn--danger"
|
||||
onClick={handleDelete}
|
||||
aria-label="Delete conversation"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
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="xs"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface MessageContextValue {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export const MessageContext = createContext<MessageContextValue>({
|
||||
messageId: '',
|
||||
});
|
||||
|
||||
export const useMessageContext = (): MessageContextValue =>
|
||||
useContext(MessageContext);
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { 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="xs"
|
||||
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="xs"
|
||||
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="xs"
|
||||
variant="ghost"
|
||||
onClick={(): void => handleVote('negative')}
|
||||
>
|
||||
<ThumbsDown size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{onRegenerate && (
|
||||
<Tooltip title="Regenerate">
|
||||
<Button
|
||||
className="ai-message-feedback__btn"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="ai-message-feedback__time">
|
||||
{relativeTime} · {absoluteTime}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import {
|
||||
PendingApproval,
|
||||
PendingClarification,
|
||||
StreamingEventItem,
|
||||
} from '../types';
|
||||
import ApprovalCard from './ApprovalCard';
|
||||
import { RichCodeBlock } from './blocks';
|
||||
import ClarificationForm from './ClarificationForm';
|
||||
import ThinkingStep from './ThinkingStep';
|
||||
import ToolCallStep from './ToolCallStep';
|
||||
|
||||
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
const childArr = React.Children.toArray(children);
|
||||
if (childArr.length === 1) {
|
||||
const child = childArr[0];
|
||||
if (React.isValidElement(child) && child.type !== 'code') {
|
||||
return <>{child}</>;
|
||||
}
|
||||
}
|
||||
return <pre>{children}</pre>;
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
/** Human-readable labels for execution status codes shown before any events arrive. */
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
queued: 'Queued…',
|
||||
running: 'Thinking…',
|
||||
awaiting_approval: 'Waiting for your approval…',
|
||||
awaiting_clarification: 'Waiting for your input…',
|
||||
resumed: 'Resumed…',
|
||||
};
|
||||
|
||||
interface StreamingMessageProps {
|
||||
conversationId: string;
|
||||
/** Ordered timeline of text and tool-call events in arrival order. */
|
||||
events: StreamingEventItem[];
|
||||
status?: string;
|
||||
pendingApproval?: PendingApproval | null;
|
||||
pendingClarification?: PendingClarification | null;
|
||||
}
|
||||
|
||||
export default function StreamingMessage({
|
||||
conversationId,
|
||||
events,
|
||||
status = '',
|
||||
pendingApproval = null,
|
||||
pendingClarification = null,
|
||||
}: StreamingMessageProps): JSX.Element {
|
||||
const statusLabel = STATUS_LABEL[status] ?? '';
|
||||
const isEmpty =
|
||||
events.length === 0 && !pendingApproval && !pendingClarification;
|
||||
|
||||
return (
|
||||
<div className="ai-message ai-message--assistant ai-message--streaming">
|
||||
<div className="ai-message__bubble">
|
||||
{/* Status pill or typing indicator — only before any events arrive */}
|
||||
{isEmpty && statusLabel && (
|
||||
<span className="ai-streaming-status">{statusLabel}</span>
|
||||
)}
|
||||
{isEmpty && !statusLabel && (
|
||||
<span className="ai-message__typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* eslint-disable react/no-array-index-key */}
|
||||
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
|
||||
{events.map((event, i) => {
|
||||
if (event.kind === 'tool') {
|
||||
return <ToolCallStep key={i} toolCall={event.toolCall} />;
|
||||
}
|
||||
if (event.kind === 'thinking') {
|
||||
return <ThinkingStep key={i} content={event.content} />;
|
||||
}
|
||||
return (
|
||||
<ReactMarkdown
|
||||
key={i}
|
||||
className="ai-message__markdown"
|
||||
remarkPlugins={MD_PLUGINS}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
{event.content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
})}
|
||||
{/* eslint-enable react/no-array-index-key */}
|
||||
|
||||
{/* Approval / clarification cards appended after any streamed text */}
|
||||
{pendingApproval && (
|
||||
<ApprovalCard conversationId={conversationId} approval={pendingApproval} />
|
||||
)}
|
||||
{pendingClarification && (
|
||||
<ClarificationForm
|
||||
conversationId={conversationId}
|
||||
clarification={pendingClarification}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface ThinkingStepProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Displays a collapsible thinking/reasoning block. */
|
||||
export default function ThinkingStep({
|
||||
content,
|
||||
}: ThinkingStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="ai-thinking-step">
|
||||
<button
|
||||
type="button"
|
||||
className="ai-thinking-step__header"
|
||||
onClick={(): void => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Brain size={12} className="ai-thinking-step__icon" />
|
||||
<span className="ai-thinking-step__label">Thinking</span>
|
||||
{expanded ? (
|
||||
<ChevronDown size={11} className="ai-thinking-step__chevron" />
|
||||
) : (
|
||||
<ChevronRight size={11} className="ai-thinking-step__chevron" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="ai-thinking-step__body">
|
||||
<p className="ai-thinking-step__content">{content}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Loader2, Wrench } from 'lucide-react';
|
||||
|
||||
import { StreamingToolCall } from '../types';
|
||||
|
||||
interface ToolCallStepProps {
|
||||
toolCall: StreamingToolCall;
|
||||
}
|
||||
|
||||
/** Displays a single tool invocation, collapsible, with in/out detail. */
|
||||
export default function ToolCallStep({
|
||||
toolCall,
|
||||
}: ToolCallStepProps): JSX.Element {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { toolName, input, result, done } = toolCall;
|
||||
|
||||
// Format tool name: "signoz_get_dashboard" → "Get Dashboard"
|
||||
const label = toolName
|
||||
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ai-tool-step ${
|
||||
done ? 'ai-tool-step--done' : 'ai-tool-step--running'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ai-tool-step__header"
|
||||
onClick={(): void => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{done ? (
|
||||
<Wrench
|
||||
size={12}
|
||||
className="ai-tool-step__icon ai-tool-step__icon--done"
|
||||
/>
|
||||
) : (
|
||||
<Loader2
|
||||
size={12}
|
||||
className="ai-tool-step__icon ai-tool-step__icon--spin"
|
||||
/>
|
||||
)}
|
||||
<span className="ai-tool-step__label">{label}</span>
|
||||
<span className="ai-tool-step__tool-name">{toolName}</span>
|
||||
{expanded ? (
|
||||
<ChevronDown size={11} className="ai-tool-step__chevron" />
|
||||
) : (
|
||||
<ChevronRight size={11} className="ai-tool-step__chevron" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="ai-tool-step__body">
|
||||
<div className="ai-tool-step__section">
|
||||
<span className="ai-tool-step__section-label">Input</span>
|
||||
<pre className="ai-tool-step__json">{JSON.stringify(input, null, 2)}</pre>
|
||||
</div>
|
||||
{done && result !== undefined && (
|
||||
<div className="ai-tool-step__section">
|
||||
<span className="ai-tool-step__section-label">Output</span>
|
||||
<pre className="ai-tool-step__json">
|
||||
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { Activity, AlertTriangle, BarChart3, Search, Zap } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../store/useAIAssistantStore';
|
||||
import { Message, StreamingEventItem } from '../types';
|
||||
import AIAssistantIcon from './AIAssistantIcon';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import StreamingMessage from './StreamingMessage';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
{
|
||||
icon: AlertTriangle,
|
||||
text: 'Show me the top errors in the last hour',
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
text: 'What services have the highest latency?',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
text: 'Give me an overview of system health',
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
text: 'Find slow database queries',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
text: 'Which endpoints have the most 5xx errors?',
|
||||
},
|
||||
];
|
||||
|
||||
const EMPTY_EVENTS: StreamingEventItem[] = [];
|
||||
|
||||
interface VirtualizedMessagesProps {
|
||||
conversationId: string;
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export default function VirtualizedMessages({
|
||||
conversationId,
|
||||
messages,
|
||||
isStreaming,
|
||||
}: VirtualizedMessagesProps): JSX.Element {
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
const streamingStatus = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingStatus ?? '',
|
||||
);
|
||||
const streamingEvents = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.streamingEvents ?? EMPTY_EVENTS,
|
||||
);
|
||||
const pendingApproval = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingApproval ?? null,
|
||||
);
|
||||
const pendingClarification = useAIAssistantStore(
|
||||
(s) => s.streams[conversationId]?.pendingClarification ?? null,
|
||||
);
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
|
||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user');
|
||||
const handleRegenerate = useCallback((): void => {
|
||||
if (lastUserMessage && !isStreaming) {
|
||||
sendMessage(lastUserMessage.content, lastUserMessage.attachments);
|
||||
}
|
||||
}, [lastUserMessage, isStreaming, sendMessage]);
|
||||
|
||||
// Scroll to bottom on new messages, streaming progress, or interactive cards
|
||||
useEffect(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: 'LAST',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [
|
||||
messages.length,
|
||||
streamingEvents.length,
|
||||
isStreaming,
|
||||
pendingApproval,
|
||||
pendingClarification,
|
||||
]);
|
||||
|
||||
const followOutput = useCallback(
|
||||
(atBottom: boolean): false | 'smooth' =>
|
||||
atBottom || isStreaming ? 'smooth' : false,
|
||||
[isStreaming],
|
||||
);
|
||||
|
||||
const showStreamingSlot =
|
||||
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
|
||||
|
||||
if (messages.length === 0 && !showStreamingSlot) {
|
||||
return (
|
||||
<div className="ai-messages__empty">
|
||||
<div className="ai-empty__icon">
|
||||
<AIAssistantIcon size={40} />
|
||||
</div>
|
||||
<h3 className="ai-empty__title">SigNoz AI Assistant</h3>
|
||||
<p className="ai-empty__subtitle">
|
||||
Ask questions about your traces, logs, metrics, and infrastructure.
|
||||
</p>
|
||||
<div className="ai-empty__suggestions">
|
||||
{SUGGESTIONS.map((s) => (
|
||||
<button
|
||||
key={s.text}
|
||||
type="button"
|
||||
className="ai-empty__chip"
|
||||
onClick={(): void => {
|
||||
sendMessage(s.text);
|
||||
}}
|
||||
>
|
||||
<s.icon size={14} />
|
||||
{s.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount = messages.length + (showStreamingSlot ? 1 : 0);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
className="ai-messages"
|
||||
totalCount={totalCount}
|
||||
followOutput={followOutput}
|
||||
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
|
||||
itemContent={(index): JSX.Element => {
|
||||
if (index < messages.length) {
|
||||
const msg = messages[index];
|
||||
const isLastAssistant =
|
||||
msg.role === 'assistant' &&
|
||||
messages.slice(index + 1).every((m) => m.role !== 'assistant');
|
||||
return (
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
onRegenerate={
|
||||
isLastAssistant && !showStreamingSlot ? handleRegenerate : undefined
|
||||
}
|
||||
isLastAssistant={isLastAssistant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StreamingMessage
|
||||
conversationId={conversationId}
|
||||
events={streamingEvents}
|
||||
status={streamingStatus}
|
||||
pendingApproval={pendingApproval}
|
||||
pendingClarification={pendingClarification}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { AlertCircle, Check, Loader2, X, Zap } from 'lucide-react';
|
||||
|
||||
import { PageActionRegistry } from '../../pageActions/PageActionRegistry';
|
||||
import { AIActionBlock } from '../../pageActions/types';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../MessageContext';
|
||||
|
||||
type BlockState = 'pending' | 'loading' | 'applied' | 'dismissed' | 'error';
|
||||
|
||||
/**
|
||||
* Renders an AI-suggested page action.
|
||||
*
|
||||
* Two modes based on the registered PageAction.autoApply flag:
|
||||
*
|
||||
* autoApply = false (default)
|
||||
* Shows a confirmation card with Accept / Dismiss. The user must
|
||||
* explicitly approve before execute() is called. Use for destructive or
|
||||
* hard-to-reverse actions (create dashboard, delete alert, etc.).
|
||||
*
|
||||
* autoApply = true
|
||||
* Executes immediately on mount — no card shown, just the result summary.
|
||||
* Use for low-risk, reversible actions where the user explicitly asked for
|
||||
* the change (e.g. "filter logs for errors"). Avoids unnecessary friction.
|
||||
*
|
||||
* Persists answered state via answeredBlocks so re-mounts don't reset UI.
|
||||
*/
|
||||
export default function ActionBlock({
|
||||
data,
|
||||
}: {
|
||||
data: AIActionBlock;
|
||||
}): JSX.Element {
|
||||
const { messageId } = useMessageContext();
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
|
||||
const [localState, setLocalState] = useState<BlockState>(() => {
|
||||
if (!messageId) {
|
||||
return 'pending';
|
||||
}
|
||||
const saved = answeredBlocks[messageId];
|
||||
if (!saved) {
|
||||
return 'pending';
|
||||
}
|
||||
if (saved === 'dismissed') {
|
||||
return 'dismissed';
|
||||
}
|
||||
if (saved.startsWith('error:')) {
|
||||
return 'error';
|
||||
}
|
||||
return 'applied';
|
||||
});
|
||||
const [resultSummary, setResultSummary] = useState<string>('');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
const { actionId, description, parameters } = data;
|
||||
|
||||
// ── Shared execute logic ─────────────────────────────────────────────────────
|
||||
|
||||
const execute = async (): Promise<void> => {
|
||||
const action = PageActionRegistry.get(actionId);
|
||||
|
||||
if (!action) {
|
||||
const msg = `Action "${actionId}" is not available on the current page.`;
|
||||
setErrorMessage(msg);
|
||||
setLocalState('error');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, `error:${msg}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalState('loading');
|
||||
|
||||
try {
|
||||
const result = await action.execute(parameters as never);
|
||||
setResultSummary(result.summary);
|
||||
setLocalState('applied');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, `applied:${result.summary}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
setErrorMessage(msg);
|
||||
setLocalState('error');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, `error:${msg}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Auto-apply: fire immediately on mount if the action opts in ──────────────
|
||||
|
||||
const autoApplyFired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-apply once, and only when the block hasn't been answered yet
|
||||
// (i.e. this is a fresh render, not a remount of an already-answered block).
|
||||
if (autoApplyFired.current || localState !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = PageActionRegistry.get(actionId);
|
||||
if (!action?.autoApply) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoApplyFired.current = true;
|
||||
execute();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleDismiss = (): void => {
|
||||
setLocalState('dismissed');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, 'dismissed');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Terminal states ──────────────────────────────────────────────────────────
|
||||
|
||||
if (localState === 'applied') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--applied">
|
||||
<Check
|
||||
size={13}
|
||||
className="ai-action__status-icon ai-action__status-icon--ok"
|
||||
/>
|
||||
<span className="ai-action__status-text">
|
||||
{resultSummary || 'Applied.'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (localState === 'dismissed') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--dismissed">
|
||||
<X
|
||||
size={13}
|
||||
className="ai-action__status-icon ai-action__status-icon--no"
|
||||
/>
|
||||
<span className="ai-action__status-text">Dismissed.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (localState === 'error') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--error">
|
||||
<AlertCircle
|
||||
size={13}
|
||||
className="ai-action__status-icon ai-action__status-icon--err"
|
||||
/>
|
||||
<span className="ai-action__status-text">{errorMessage}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Loading (autoApply in progress) ─────────────────────────────────────────
|
||||
|
||||
if (localState === 'loading') {
|
||||
return (
|
||||
<div className="ai-block ai-action ai-action--loading">
|
||||
<Loader2 size={13} className="ai-action__spinner ai-action__status-icon" />
|
||||
<span className="ai-action__status-text">{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pending: manual confirmation card ────────────────────────────────────────
|
||||
|
||||
const paramEntries = Object.entries(parameters ?? {});
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-action">
|
||||
<div className="ai-action__header">
|
||||
<Zap size={13} className="ai-action__zap-icon" />
|
||||
<span className="ai-action__header-label">Suggested Action</span>
|
||||
</div>
|
||||
|
||||
<p className="ai-action__description">{description}</p>
|
||||
|
||||
{paramEntries.length > 0 && (
|
||||
<ul className="ai-action__params">
|
||||
{paramEntries.map(([key, val]) => (
|
||||
<li key={key} className="ai-action__param">
|
||||
<span className="ai-action__param-key">{key}</span>
|
||||
<span className="ai-action__param-val">
|
||||
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="ai-action__actions">
|
||||
<Button variant="solid" size="xs" onClick={execute}>
|
||||
<Check size={12} />
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="outlined" size="xs" onClick={handleDismiss}>
|
||||
<X size={12} />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
|
||||
import { CHART_PALETTE, getChartTheme } from './chartSetup';
|
||||
|
||||
export interface BarDataset {
|
||||
label?: string;
|
||||
data: number[];
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface BarChartData {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
/**
|
||||
* Category labels (x-axis for vertical, y-axis for horizontal).
|
||||
* Shorthand: omit `datasets` and use `bars` for single-series data.
|
||||
*/
|
||||
labels?: string[];
|
||||
datasets?: BarDataset[];
|
||||
/** Single-series shorthand: [{ label, value }] */
|
||||
bars?: { label: string; value: number; color?: string }[];
|
||||
/** 'vertical' (default) | 'horizontal' */
|
||||
direction?: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
export default function BarChartBlock({
|
||||
data,
|
||||
}: {
|
||||
data: BarChartData;
|
||||
}): JSX.Element {
|
||||
const { title, unit, direction = 'horizontal' } = data;
|
||||
const theme = getChartTheme();
|
||||
|
||||
// Normalise shorthand `bars` → labels + datasets
|
||||
let labels: string[];
|
||||
let datasets: BarDataset[];
|
||||
|
||||
if (data.bars) {
|
||||
labels = data.bars.map((b) => b.label);
|
||||
datasets = [
|
||||
{
|
||||
label: title ?? 'Value',
|
||||
data: data.bars.map((b) => b.value),
|
||||
color: undefined, // use palette below
|
||||
},
|
||||
];
|
||||
} else {
|
||||
labels = data.labels ?? [];
|
||||
datasets = data.datasets ?? [];
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: datasets.map((ds, i) => {
|
||||
const baseColor = ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length];
|
||||
return {
|
||||
label: ds.label ?? `Series ${i + 1}`,
|
||||
data: ds.data,
|
||||
backgroundColor: data.bars
|
||||
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
|
||||
: baseColor,
|
||||
borderColor: data.bars
|
||||
? data.bars.map((_, j) => CHART_PALETTE[j % CHART_PALETTE.length])
|
||||
: baseColor,
|
||||
borderWidth: 1,
|
||||
borderRadius: 3,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const barHeight = Math.max(160, labels.length * 28 + 48);
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-chart">
|
||||
{title && <p className="ai-block__title">{title}</p>}
|
||||
<div
|
||||
className="ai-chart__canvas-wrap"
|
||||
style={{ height: direction === 'horizontal' ? barHeight : 200 }}
|
||||
>
|
||||
<Bar
|
||||
data={chartData}
|
||||
options={{
|
||||
indexAxis: direction === 'horizontal' ? 'y' : 'x',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
labels: { color: theme.legendColor, boxWidth: 12, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: theme.tooltipBg,
|
||||
titleColor: theme.tooltipText,
|
||||
bodyColor: theme.tooltipText,
|
||||
borderColor: theme.gridColor,
|
||||
borderWidth: 1,
|
||||
callbacks: unit
|
||||
? { label: (ctx): string => `${ctx.formattedValue} ${unit}` }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: {
|
||||
color: theme.tickColor,
|
||||
font: { size: 11 },
|
||||
callback:
|
||||
unit && direction !== 'horizontal'
|
||||
? (v): string => `${v} ${unit}`
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: { color: theme.tickColor, font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type BlockComponent<T = any> = React.ComponentType<{ data: T }>;
|
||||
|
||||
/**
|
||||
* Global registry for AI response block renderers.
|
||||
*
|
||||
* Any part of the application can register a custom block type:
|
||||
*
|
||||
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
|
||||
* BlockRegistry.register('my-panel', MyPanelComponent);
|
||||
*
|
||||
* The AI can then emit fenced code blocks with the prefix `ai-<type>` and a
|
||||
* JSON payload, and the registered component will be rendered in-place:
|
||||
*
|
||||
* ```ai-my-panel
|
||||
* { "foo": "bar" }
|
||||
* ```
|
||||
*/
|
||||
const _registry = new Map<string, BlockComponent>();
|
||||
|
||||
export const BlockRegistry = {
|
||||
register<T>(type: string, component: BlockComponent<T>): void {
|
||||
_registry.set(type, component as BlockComponent);
|
||||
},
|
||||
|
||||
get(type: string): BlockComponent | undefined {
|
||||
return _registry.get(type);
|
||||
},
|
||||
|
||||
/** Returns all registered type names (useful for debugging). */
|
||||
types(): string[] {
|
||||
return Array.from(_registry.keys());
|
||||
},
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Check, X } from 'lucide-react';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../MessageContext';
|
||||
|
||||
export interface ConfirmData {
|
||||
message?: string;
|
||||
/** Text sent back when accepted. Defaults to "Yes, proceed." */
|
||||
acceptText?: string;
|
||||
/** Text sent back when rejected. Defaults to "No, cancel." */
|
||||
rejectText?: string;
|
||||
/** Label shown on Accept button. Defaults to "Accept" */
|
||||
acceptLabel?: string;
|
||||
/** Label shown on Reject button. Defaults to "Reject" */
|
||||
rejectLabel?: string;
|
||||
}
|
||||
|
||||
export default function ConfirmBlock({
|
||||
data,
|
||||
}: {
|
||||
data: ConfirmData;
|
||||
}): JSX.Element {
|
||||
const {
|
||||
message,
|
||||
acceptText = 'Yes, proceed.',
|
||||
rejectText = 'No, cancel.',
|
||||
acceptLabel = 'Accept',
|
||||
rejectLabel = 'Reject',
|
||||
} = data;
|
||||
|
||||
const { messageId } = useMessageContext();
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
|
||||
// Durable answered state — survives re-renders/remounts
|
||||
const answeredChoice = messageId ? answeredBlocks[messageId] : undefined;
|
||||
const isAnswered = answeredChoice !== undefined;
|
||||
|
||||
const handle = (choice: 'accepted' | 'rejected'): void => {
|
||||
const responseText = choice === 'accepted' ? acceptText : rejectText;
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, choice);
|
||||
}
|
||||
sendMessage(responseText);
|
||||
};
|
||||
|
||||
if (isAnswered) {
|
||||
const wasAccepted = answeredChoice === 'accepted';
|
||||
const icon = wasAccepted ? (
|
||||
<Check size={13} className="ai-confirm__icon ai-confirm__icon--ok" />
|
||||
) : (
|
||||
<X size={13} className="ai-confirm__icon ai-confirm__icon--no" />
|
||||
);
|
||||
return (
|
||||
<div className="ai-block ai-confirm ai-confirm--answered">
|
||||
{icon}
|
||||
<span className="ai-confirm__answer-text">
|
||||
{wasAccepted ? acceptText : rejectText}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-confirm">
|
||||
{message && <p className="ai-confirm__message">{message}</p>}
|
||||
<div className="ai-confirm__actions">
|
||||
<Button variant="solid" size="xs" onClick={(): void => handle('accepted')}>
|
||||
<Check size={12} />
|
||||
{acceptLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="xs"
|
||||
onClick={(): void => handle('rejected')}
|
||||
>
|
||||
<X size={12} />
|
||||
{rejectLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Checkbox, Radio } from 'antd';
|
||||
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
import { useMessageContext } from '../MessageContext';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface QuestionData {
|
||||
question?: string;
|
||||
type?: 'radio' | 'checkbox';
|
||||
options: (string | Option)[];
|
||||
}
|
||||
|
||||
function normalizeOption(opt: string | Option): Option {
|
||||
return typeof opt === 'string' ? { value: opt, label: opt } : opt;
|
||||
}
|
||||
|
||||
export default function InteractiveQuestion({
|
||||
data,
|
||||
}: {
|
||||
data: QuestionData;
|
||||
}): JSX.Element {
|
||||
const { question, type = 'radio', options } = data;
|
||||
const normalized = options.map(normalizeOption);
|
||||
|
||||
const { messageId } = useMessageContext();
|
||||
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
|
||||
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
|
||||
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
|
||||
|
||||
// Persist selected state locally only for the pending (not-yet-submitted) case
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
// Durable answered state from the store — survives re-renders/remounts
|
||||
const answeredText = messageId ? answeredBlocks[messageId] : undefined;
|
||||
const isAnswered = answeredText !== undefined;
|
||||
|
||||
const handleSubmit = (values: string[]): void => {
|
||||
if (values.length === 0) {
|
||||
return;
|
||||
}
|
||||
const answer = values.join(', ');
|
||||
if (messageId) {
|
||||
markBlockAnswered(messageId, answer);
|
||||
}
|
||||
sendMessage(answer);
|
||||
};
|
||||
|
||||
if (isAnswered) {
|
||||
return (
|
||||
<div className="ai-block ai-question ai-question--answered">
|
||||
<span className="ai-question__check">✓</span>
|
||||
<span className="ai-question__answer-text">{answeredText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-question">
|
||||
{question && <p className="ai-block__title">{question}</p>}
|
||||
|
||||
{type === 'radio' ? (
|
||||
<Radio.Group
|
||||
className="ai-question__options"
|
||||
onChange={(e): void => {
|
||||
setSelected([e.target.value]);
|
||||
handleSubmit([e.target.value]);
|
||||
}}
|
||||
>
|
||||
{normalized.map((opt) => (
|
||||
<Radio key={opt.value} value={opt.value} className="ai-question__option">
|
||||
{opt.label}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox.Group
|
||||
className="ai-question__options ai-question__options--checkbox"
|
||||
onChange={(vals): void => setSelected(vals as string[])}
|
||||
>
|
||||
{normalized.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="ai-question__option"
|
||||
>
|
||||
{opt.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="xs"
|
||||
className="ai-question__submit"
|
||||
disabled={selected.length === 0}
|
||||
onClick={(): void => handleSubmit(selected)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Line } from 'react-chartjs-2';
|
||||
|
||||
import {
|
||||
CHART_PALETTE,
|
||||
CHART_PALETTE_ALPHA,
|
||||
getChartTheme,
|
||||
} from './chartSetup';
|
||||
|
||||
export interface LineDataset {
|
||||
label?: string;
|
||||
data: number[];
|
||||
color?: string;
|
||||
/** Fill area under line. Defaults to false. */
|
||||
fill?: boolean;
|
||||
}
|
||||
|
||||
export interface LineChartData {
|
||||
title?: string;
|
||||
unit?: string;
|
||||
/** X-axis labels (time strings, numbers, etc.) */
|
||||
labels: string[];
|
||||
datasets: LineDataset[];
|
||||
}
|
||||
|
||||
export default function LineChartBlock({
|
||||
data,
|
||||
}: {
|
||||
data: LineChartData;
|
||||
}): JSX.Element {
|
||||
const { title, unit, labels, datasets } = data;
|
||||
const theme = getChartTheme();
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: datasets.map((ds, i) => {
|
||||
const color = ds.color ?? CHART_PALETTE[i % CHART_PALETTE.length];
|
||||
const fillColor = CHART_PALETTE_ALPHA[i % CHART_PALETTE_ALPHA.length];
|
||||
return {
|
||||
label: ds.label ?? `Series ${i + 1}`,
|
||||
data: ds.data,
|
||||
borderColor: color,
|
||||
backgroundColor: ds.fill ? fillColor : 'transparent',
|
||||
pointBackgroundColor: color,
|
||||
pointRadius: labels.length > 30 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
borderWidth: 2,
|
||||
fill: ds.fill ?? false,
|
||||
tension: 0.35,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ai-block ai-chart">
|
||||
{title && <p className="ai-block__title">{title}</p>}
|
||||
<div className="ai-chart__canvas-wrap">
|
||||
<Line
|
||||
data={chartData}
|
||||
options={{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
labels: { color: theme.legendColor, boxWidth: 12, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: theme.tooltipBg,
|
||||
titleColor: theme.tooltipText,
|
||||
bodyColor: theme.tooltipText,
|
||||
borderColor: theme.gridColor,
|
||||
borderWidth: 1,
|
||||
callbacks: unit
|
||||
? { label: (ctx): string => ` ${ctx.formattedValue} ${unit}` }
|
||||
: {},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: {
|
||||
color: theme.tickColor,
|
||||
font: { size: 11 },
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 8,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: theme.gridColor },
|
||||
ticks: {
|
||||
color: theme.tickColor,
|
||||
font: { size: 11 },
|
||||
callback: unit ? (v): string => `${v} ${unit}` : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user