Compare commits

..

42 Commits

Author SHA1 Message Date
SagarRajput-7
8d94a4747b fix(base-path): rename loadModule to loadStorageModule to avoid TS global scope collision 2026-04-21 09:54:10 +05:30
SagarRajput-7
b256f83a6e fix(base-path): prepend withBasePath to livetail SSE URL in dev mode 2026-04-21 09:36:33 +05:30
SagarRajput-7
8513c22bf5 feat(base-path): eslint rule, ban direct localStorage/sessionStorage access 2026-04-21 09:32:19 +05:30
SagarRajput-7
bf5c49a2a4 feat(base-path): route direct sessionStorage calls through scoped wrappers 2026-04-21 09:23:05 +05:30
SagarRajput-7
02dcbae9b5 feat(base-path): route direct localStorage calls through scoped wrappers 2026-04-21 09:19:48 +05:30
SagarRajput-7
32ff1e6924 feat(base-path): sessionStorage wrappers + scope useLocalStorage via wrappers 2026-04-21 09:11:44 +05:30
SagarRajput-7
9f63f8a506 feat(base-path): scope localStorage wrapper keys via getScopedKey 2026-04-21 09:09:41 +05:30
SagarRajput-7
49b7bc2e5b feat(base-path): getScopedKey — scope storage keys to base path 2026-04-21 09:08:15 +05:30
SagarRajput-7
3a5d3225dd feat(base-path): migrate backend bound urls and eslint upgrade to error 2026-04-20 15:42:10 +05:30
SagarRajput-7
b61376b904 feat(base-path): migrate remaining pattern for window.location.origin + path 2026-04-20 14:18:59 +05:30
SagarRajput-7
18429b3b91 feat(base-path): replace window.open with openInNewTab for internal paths 2026-04-20 14:05:16 +05:30
SagarRajput-7
c7adb22c61 feat: code refactor around feedbacks 2026-04-20 13:34:46 +05:30
SagarRajput-7
b936e63658 feat: applied suggested patch changes 2026-04-20 12:14:15 +05:30
SagarRajput-7
727b105b6c Merge branch 'main' into base-path-config-1 2026-04-20 12:02:01 +05:30
SagarRajput-7
f3269318b7 feat: code refactor around feedbacks 2026-04-17 15:58:00 +05:30
SagarRajput-7
5088bd7499 feat: updated base path utils and fixed navigation and translations 2026-04-17 13:52:12 +05:30
SagarRajput-7
7648d4f3d3 feat: updated the html template 2026-04-16 21:55:21 +05:30
SagarRajput-7
5729a4584a feat: removed plugin and serving the index.html only as the template 2026-04-16 18:23:46 +05:30
SagarRajput-7
825d06249d feat: refactor the interceptor and added gotmpl into gitignore 2026-04-16 18:23:38 +05:30
SagarRajput-7
9034471587 feat: changed output path to dir level 2026-04-16 18:23:25 +05:30
SagarRajput-7
4cc23ead6b feat: base path config setup and plugin for gotmpl generation at build time 2026-04-16 18:19:07 +05:30
SagarRajput-7
867e27d45f Merge branch 'main' into platform-pod/issues/1775 2026-04-16 18:17:11 +05:30
grandwizard28
be37e588f8 perf(web): cache http.FileServer on provider instead of creating per-request 2026-04-16 14:53:06 +05:30
grandwizard28
057dcbe6e4 fix: remove unused files 2026-04-16 02:26:18 +05:30
grandwizard28
3a28d741a3 fix: remove unused files 2026-04-16 02:24:20 +05:30
grandwizard28
223e83154f style: formatting and test cleanup from review
Restructure Validate nil check, rename expectErr to fail with
early-return, trim trailing newlines in test assertions, remove
t.Parallel from subtests, inline short config literals, restore
struct field comments in web.Config.
2026-04-16 02:17:14 +05:30
grandwizard28
50ae51cdaa fix(web): resolve lint errors in provider and template
Fix errcheck on rw.Write in serveIndex, use ErrorContext instead of
Error in NewIndex for sloglint compliance. Move serveIndex below
ServeHTTP to order public methods before private ones.
2026-04-16 02:05:25 +05:30
grandwizard28
c8ae8476c3 style: add blank lines between logical blocks 2026-04-16 01:57:24 +05:30
grandwizard28
daaa66e1fc chore: remove redundant comments from added code 2026-04-16 01:54:14 +05:30
grandwizard28
b0717d6a69 refactor(web): use table-driven tests with named path cases
Replace for-loop path iteration with explicit table-driven test cases
for each path. Each path (root, non-existent, directory) is a named
subtest case in all three template tests.
2026-04-16 01:49:07 +05:30
grandwizard28
4aefe44313 refactor(web): rename get test helper to httpGet 2026-04-16 01:47:35 +05:30
grandwizard28
4dc6f6fe7b style(web): use raw string literals for expected test values 2026-04-16 01:44:46 +05:30
grandwizard28
d3e0c46ba2 test(web): use exact match instead of contains in template tests
Match the full expected response body in TestServeTemplatedIndex
instead of using assert.Contains.
2026-04-16 01:43:23 +05:30
grandwizard28
0fed17e11a test(web): add SPA fallback paths to no_template and invalid_template tests
Test /, /does-not-exist, and /assets in all three template test cases
to verify SPA fallback behavior (non-existent paths and directories
serve the index) regardless of template type.
2026-04-16 01:38:46 +05:30
grandwizard28
a2264b4960 refactor(web): rename test fixtures to no_template, valid_template, invalid_template
Drop the index_ prefix from test fixtures. Use web instead of w for
the variable name in test helpers.
2026-04-16 01:32:50 +05:30
grandwizard28
2740964106 test(web): add no-template and invalid-template index test cases
Add three distinct index fixtures in testdata:
- index.html: correct [[ ]] template with BaseHref
- index_no_template.html: plain HTML, no placeholders
- index_invalid_template.html: malformed template syntax

Tests verify: template substitution works, plain files pass through
unchanged, and invalid templates fall back to serving raw bytes.
Consolidate test helpers into startServer/get.
2026-04-16 01:28:37 +05:30
grandwizard28
0ca22dd7fe refactor(web): collapse testdata_basepath into testdata
Use a single testdata directory with a templated index.html for all
routerweb tests. Remove the redundant testdata_basepath directory.
2026-04-16 01:22:54 +05:30
grandwizard28
a3b6bddac8 refactor(web): make index filename configurable via web.index
Move the hardcoded indexFileName const from routerweb/provider.go to
web.Config.Index with default "index.html". This allows overriding the
SPA entrypoint file via configuration.
2026-04-16 01:19:35 +05:30
grandwizard28
d908ce321a refactor(global): rename RoutePrefix to ExternalPath, add ExternalPathTrailing
Rename RoutePrefix() to ExternalPath() to accurately reflect what it
returns: the path component of the external URL. Add
ExternalPathTrailing() which returns the path with a trailing slash,
used for HTML base href injection.
2026-04-16 01:13:16 +05:30
grandwizard28
c221a44f3d refactor(web): extract index.html templating into web.NewIndex
Move the template parsing and execution logic from routerweb provider
into pkg/web/template.go. NewIndex logs and returns raw bytes on
template failure; NewIndexE returns the error for callers that need it.

Rename BasePath to BaseHref to match the HTML attribute it populates.
Inject global.Config into routerweb via the factory closure pattern.
2026-04-16 01:08:46 +05:30
grandwizard28
22fb4daaf9 feat(web): template index.html with dynamic base href from global.external_url
Read index.html at startup, parse as Go template with [[ ]] delimiters,
execute with BasePath derived from global.external_url, and cache the
rendered bytes in memory. This injects <base href="/signoz/" /> (or
whatever the route prefix is) so the browser resolves relative URLs
correctly when SigNoz is served at a sub-path.

Inject global.Config into the routerweb provider via the factory closure
pattern. Static files (JS, CSS, images) are still served from disk
unchanged.
2026-04-16 00:58:20 +05:30
grandwizard28
1bdc059d76 feat(apiserver): derive HTTP route prefix from global.external_url
The path component of global.external_url is now used as the base path
for all HTTP routes (API and web frontend), enabling SigNoz to be served
behind a reverse proxy at a sub-path (e.g. https://example.com/signoz/).

The prefix is applied via http.StripPrefix at the outermost handler
level, requiring zero changes to route registration code. Health
endpoints (/api/v1/health, /api/v2/healthz, /api/v2/readyz,
/api/v2/livez) remain accessible without the prefix for container
healthchecks.

Removes web.prefix config in favor of the unified global.external_url
approach, avoiding the desync bugs seen in projects with separate
API/UI prefix configs (ArgoCD, Prometheus).

closes SigNoz/platform-pod#1775
2026-04-16 00:38:55 +05:30
519 changed files with 33323 additions and 71853 deletions

64
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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
}

View File

@@ -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',
},
},
{

View 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' });
},
};
},
};

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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

View File

@@ -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 {

View File

@@ -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'
),
);

View File

@@ -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 = {

View File

@@ -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: {

View 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;

View File

@@ -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 23.
* 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}`,
);
}
}

View File

@@ -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 '';
}

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View 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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<{

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>),

View File

@@ -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>),

View File

@@ -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>),

View File

@@ -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,

View File

@@ -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);
}
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>),

View 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;
};

View File

@@ -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)}`,

View File

@@ -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

View File

@@ -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';

View File

@@ -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,

View File

@@ -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(),
});
};

View File

@@ -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);
}
}
}

View File

@@ -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

View File

@@ -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');

View File

@@ -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"

View File

@@ -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()}`);
}
}

View File

@@ -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(),
}));

View File

@@ -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;

View File

@@ -0,0 +1,7 @@
import { HostData } from 'api/infraMonitoring/getHostLists';
export type HostDetailProps = {
host: HostData | null;
isModalTimeSelection: boolean;
onClose: () => void;
};

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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,
},
],
},
});

View File

@@ -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> =

View File

@@ -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;
}
}

View 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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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,
});

View File

@@ -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%);
}
}

View 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;

View File

@@ -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;

View 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,
};

View File

@@ -0,0 +1,3 @@
import HostMetricsDetails from './HostMetricsDetails';
export default HostMetricsDetails;

View File

@@ -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(),
})),
});
}

View File

@@ -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(),
});
};

View File

@@ -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 (

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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,
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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).

View File

@@ -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 1545ms 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.

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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());
},
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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