Compare commits

..

8 Commits

Author SHA1 Message Date
aks07
7d7200ead6 Merge remote-tracking branch 'origin/main' into qf-scroll-layout 2026-09-22 14:41:38 +05:30
aks07
9f20158225 fix(metrics-explorer): stop the summary table clipping inside the tab scroller 2026-09-21 19:08:44 +05:30
Aditya Singh
9833797abe Merge branch 'main' into qf-scroll-layout 2026-09-21 14:32:26 +05:30
aks07
52995b252e fix(quick-filters): size the settings drawer from the filters pane
The per-page viewport heights ignored the trial/payment banners, so the
save/discard footer fell outside the pane and the new overflow rule clipped
it. The pane is bounded to the viewport now, so height: 100% is enough.
2026-09-21 12:14:18 +05:30
aks07
deb7854b44 fix(quick-filters): let the content pane scroll horizontally
The pane clipped horizontal overflow, so a toolbar wider than the pane put
Run Query out of reach. Matches how these pages behaved before the layout.
2026-09-21 10:38:35 +05:30
aks07
f7c47408e9 Merge branch 'main' into qf-scroll-layout 2026-09-21 10:34:19 +05:30
aks07
1af45169d5 feat(quick-filters): scroll the filter sidebar and content independently
Adds QuickFiltersLayout, a bounded two-pane layout that renders QuickFilters
in a fixed-width sidebar and the page content in its own OverlayScrollbar,
and moves Traces, LLM Observability, API Monitoring, Exceptions and Meter
onto it. The sidebar is 280px on every page. Logs and Infra are unchanged.
2026-09-19 11:45:20 +05:30
aks07
0c2a874e07 feat(route-tab): scroll tab content inside the pane instead of the page
RouteTab now owns the antd Tabs height chain and wraps each pane in an
OverlayScrollbar, so the tab bar stays put and pages no longer need their
own .ant-tabs overrides. Module pages pass their class to RouteTab instead
of wrapping it in a div, which was the auto-height link that let tall
content grow the page.
2026-09-18 19:40:48 +05:30
110 changed files with 751 additions and 2407 deletions

View File

@@ -47,7 +47,6 @@ jobs:
- dashboard
- ingestionkeys
- inframonitoring
- llmpricingrules
- logspipelines
- passwordauthn
- preference

View File

@@ -12772,8 +12772,9 @@ paths:
put:
deprecated: false
description: Single write endpoint used by both the user and the Zeus sync job.
Rules without isOverride are matched by sourceId and override rows (is_override=true)
are skipped. Rules with isOverride are matched by id and inserted when new.
Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true)
are fully preserved when the request does not provide isOverride; only synced_at
is stamped.
operationId: CreateOrUpdateLLMPricingRules
requestBody:
content:

View File

@@ -1,127 +0,0 @@
---
name: scaffold-feature
description: Scaffold the co-located feature structure in frontend/src. Use when creating a new page, feature, view (tab), or component folder, when a feature needs a shell with tabs, or when moving existing code out of src/container into src/pages. Generates the full folder tree (components/hooks/store/types/utils/constants/__tests__/README) and registers the page's routes with one command.
---
# Scaffold a feature
The frontend is moving to a co-located layout (Bulletproof React / FSD): everything a
feature owns lives in the feature's folder. Read `references/layout.md` for the full
target structure and the rules about what may live where.
**Never hand-create these folders.** Run the generator so every feature comes out
identical, then fill it in.
## Command
```bash
pnpm scaffold page <Name> [options] # a page/feature under src/pages
pnpm scaffold component <Name> [options] # a component folder
```
| Option | Applies to | Effect |
| --- | --- | --- |
| `--views A,B,C` | `page` | Makes the page a shell with tab switching and generates one view folder per name. |
| `--parent <path>` | `component` | Parent, relative to `src` (default `components`). A feature path like `pages/Traces/Explorer` nests the component under that feature's `components/`. |
| `--full` | `component` | Also adds `components/`, `hooks/`, `store/`, `types.ts`, `utils.ts`, `constants.ts`, `README.md` for a component that owns children. |
| `--no-tests` | both | Skips `__tests__/`. |
| `--dry-run` | both | Prints what would be written, writes nothing. |
| `--force` | both | Overwrites files that already exist (off by default; existing entries are reported as skipped). |
Folder names keep the casing you type, with the first letter forced up, so
`LLMObservability` stays `LLMObservability` rather than being re-cased. Separated names
collapse to PascalCase: `api-monitoring` and `api monitoring` both give
`pages/ApiMonitoring`. Test ids, headings, tab paths and constants are all derived from
that folder name — `TracesFunnels` gives `traces-funnels-page`, `Traces Funnels` and
`TRACES_FUNNELS_TABS`.
## What you get
```
pages/ApiMonitoring/
index.tsx # the page component
ApiMonitoring.module.scss
components/ hooks/ store/ # empty, ready for the first file
types.ts utils.ts constants.ts
__tests__/ApiMonitoring.test.tsx
README.md
```
With `--views`, the root becomes a `RouteTab` shell and each view gets the tree above. The
shell mirrors the Logs and Traces root pages: `constants.tsx` exports one `TabRoutes` per
view (icon from `@signozhq/icons`, label, `ROUTES` key, view component), `index.tsx` composes
them into the tab bar, the SCSS module carries the tab-bar overrides, and the test asserts one
tab per view plus the active view. Tab icons come from a small name map in `scaffold.mjs`
(`Explorer`, `Funnels`, `Pipelines`, `Views`, `SavedViews`); other names get a neutral icon
to replace.
## Examples
```bash
pnpm scaffold page ApiMonitoring # leaf page, no shell
pnpm scaffold page Traces --views Explorer,Funnels,Views # shell + 3 views
pnpm scaffold page Traces/Explorer # one more view under an existing shell
pnpm scaffold component DataTable # global, src/components/DataTable
pnpm scaffold component QueryBar --parent pages/Traces/Explorer # feature-local component
```
## Route registration
`page` also registers the routes, so the page is reachable as soon as it is generated:
| File | What is added |
| --- | --- |
| `src/constants/routes.ts` | One key per path: `API_MONITORING: '/api-monitoring'` for a leaf page; `TRACES_BASE` plus `TRACES_EXPLORER`, `TRACES_FUNNELS`, … for a shell. |
| `src/utils/permission/index.ts` | A `routePermission` entry per new key, open to `ADMIN`, `EDITOR` and `VIEWER`. Tighten it if the page is admin-only. |
| `src/AppRoutes/pageComponents.ts` | A `Loadable` export named `<Page>Page` pointing at `pages/<Page>`. |
| `src/AppRoutes/routes.ts` | The import plus one private, exact route per path. For a shell the base path and every tab path render the shell; the shell redirects the base path to its first tab and `RouteTab` picks the tab otherwise. |
| `src/container/TopNav/DateTimeSelectionV2/constants.ts` | Every new path in `routesToSkip`, so the global time-range picker stays hidden until the page opts in. |
Existing keys, exports and entries are left alone, so re-running is safe. An existing key or
export that points somewhere else is a naming collision and the run stops before writing
anything. `--dry-run` lists
the edits without making them. `page Traces/Explorer` registers `TRACES_EXPLORER` pointing
at the `Traces` shell; wiring the new tab into the shell's `constants.tsx` and `index.tsx`
is still by hand. The generator never adds a SideNav item; do that in
`src/container/SideNav/menuItems.tsx` when the page needs one.
## After generating
1. **Review the route registration** (pages only) and add the SideNav entry if the page
needs one. For a view added under an existing shell, add its `TabRoutes` export to the
shell's `constants.tsx` and include it in the `routes` array in the shell's `index.tsx`.
2. **Delete the placeholders you don't need** — empty `types.ts` / `utils.ts` /
`constants.ts`, and any of `components/`, `hooks/`, `store/` the feature won't use.
Those three folders are created empty; git only picks them up once they hold a file.
3. **Fill the README** — the generated file has the prompts; a feature folder without a
filled-in README is not done.
4. **Follow the repo rules while filling it in**: `@signozhq/ui` + `@signozhq/icons` only,
CSS Modules (`docs/css-modules-guide.md`), React Query for server state (prefer
`api/generated` hooks), nuqs for URL state, Zustand for client state, `data-testid` on
every interactive element.
5. **Verify** before reporting done:
```bash
pnpm tsgo --noEmit
pnpm oxlint src/pages/<Feature>
pnpm jest src/pages/<Feature>
```
`pnpm tsgo --noEmit` is the authority. A running dev server can show errors such as
`Property 'X_BASE' does not exist` or `has no exported member 'XPage'` right after
generation. Its type-checker notices new files but, on some machines, not in-place edits
to existing ones, and the generator edits the shared files in place. If tsgo is clean,
restart `pnpm dev`.
## Editing the templates
Templates live in `templates/` — `feature/`, `shell/`, `component/` and
`component-extras/` (the `--full` additions). Every template file ends in `.tmpl`, which
keeps TypeScript, lint and your editor from reading them as source; the generator strips
that suffix on the way out, so `index.tsx.tmpl` becomes `index.tsx`. Tokens are
substituted in both file names and contents: `__Pascal__`, `__kebab__`, `__camel__`,
`__CONST__`, `__Title__`. The shell templates additionally take tokens the generator builds
from `--views`: `__ICON_IMPORTS__`, `__VIEW_IMPORTS__`, `__TAB_EXPORTS__`, `__TAB_NAMES__`,
`__BASE_ROUTE__`, `__FIRST_TAB__`, `__FIRST_VIEW_TESTID__` and `__TAB_ASSERTIONS__`. Tab icons come from
`TAB_ICONS` and the empty folders from `FEATURE_DIRS`, both in `scaffold.mjs`. Name and
route derivations live in `lib.mjs`; run `node --test .claude/skills/scaffold-feature/scaffold.test.mjs`
after changing them. Change these, not the generated
output, when the team's conventions move.

View File

@@ -1,77 +0,0 @@
const capitalize = (word) => word.charAt(0).toUpperCase() + word.slice(1);
// Folder names keep the casing the author typed — only the first letter is forced
// up — so acronyms like `LLMObservability` survive. Separated names
// (`api-monitoring`, `api monitoring`) collapse to PascalCase.
export function toDirName(value) {
const name = value.trim().replace(/[^a-zA-Z0-9\-_ ]/g, '');
if (!name) {
throw new Error(`"${value}" has no usable name characters`);
}
return /[-_\s]/.test(name)
? name
.split(/[-_\s]+/)
.filter(Boolean)
.map(capitalize)
.join('')
: capitalize(name);
}
const splitHumps = (name, separator) =>
name
.replace(/([a-z0-9])([A-Z])/g, `$1${separator}$2`)
.replace(/([A-Z]+)([A-Z][a-z])/g, `$1${separator}$2`);
export const toKebab = (value) => splitHumps(toDirName(value), '-').toLowerCase();
export const toTitle = (value) => splitHumps(toDirName(value), ' ');
export const toConst = (value) => toKebab(value).replace(/-/g, '_').toUpperCase();
export const toCamel = (value) => {
const dir = toDirName(value);
return dir.charAt(0).toLowerCase() + dir.slice(1);
};
export function tokensFor(name) {
return {
__Pascal__: toDirName(name),
__kebab__: toKebab(name),
__camel__: toCamel(name),
__CONST__: toConst(name),
__Title__: toTitle(name),
};
}
export function substitute(text, tokens) {
return Object.entries(tokens).reduce(
(acc, [token, value]) => acc.split(token).join(value),
text,
);
}
export const routeKey = (segments, view) =>
[...segments, ...(view ? [view] : [])].map(toConst).join('_');
export const routePath = (segments, view) =>
`/${[...segments, ...(view ? [view] : [])].map(toKebab).join('/')}`;
// Every path under a shell renders the shell itself (RouteTab picks the tab, the base path
// redirects to the first tab), so the page component is always the first segment.
export function routeSpec(segments, views) {
const shell = segments[0];
const component = {
name: `${shell}Page`,
importPath: `pages/${shell}`,
chunk: `${toTitle(shell)} Page`,
};
if (views.length) {
const tabs = views.map((view) => ({
key: routeKey(segments, view),
path: routePath(segments, view),
}));
const keys = [
{ key: `${routeKey(segments)}_BASE`, path: routePath(segments) },
...tabs,
];
return { component, keys, routed: keys.map(({ key }) => key) };
}
const key = routeKey(segments);
return { component, keys: [{ key, path: routePath(segments) }], routed: [key] };
}

View File

@@ -1,102 +0,0 @@
# Frontend layout
Target structure for `frontend/src`. Inspired by Bulletproof React and Feature-Sliced
Design: a feature owns its components, hooks, state, types and tests, and nothing outside
the feature folder reaches into it.
```
src/
app/ # bootstrap: routing, global styles/theme
pages/
Traces/ # has a shell
index.tsx # shell — tab switching only
constants.tsx # tab definitions
Explorer/ # a view
index.tsx # view entry — composition, no business logic
components/
QueryBar/ # same shape as a global component, nests further as needed
QueryBar.tsx
QueryBar.module.scss
components/
hooks/
__tests__/
hooks/ # feature hooks + React Query wrappers over api/generated
store/ # Zustand stores for feature-local client state
types.ts
utils.ts
constants.ts
__tests__/
README.md
Funnels/
Views/
ApiMonitoring/ # no shell — same shape, one level up
index.tsx
components/
hooks/
store/
types.ts
utils.ts
constants.ts
__tests__/
README.md
components/ # cross-feature components, same internal shape as above
DataTable/
DataTable.tsx
DataTable.module.scss
components/
hooks/
store/
types.ts
utils.ts
constants.ts
__tests__/
README.md
lib/
utils/
types/
constants/
store/ # app-wide client state only
i18n/
api/
generated/ # Orval output — never edited by hand
client/ # axios instances, interceptors, error handlers
index.tsx
```
## Rules
- **Folder names are PascalCase**, spelled the way the feature is spelled in the product
(`ApiMonitoring`, `LLMObservability`). This holds for shells, views and components alike.
- **A page folder is the unit of ownership.** Anything used by exactly one feature lives
inside it, however deeply nested. Promote to `src/components` / `src/utils` / `src/hooks`
only when a second feature needs it.
- **`index.tsx` is the entry**, and it composes. Business logic goes to `hooks/`, data
shaping to `utils.ts`, state to `store/`.
- **Nested components repeat the same shape.** A component folder may hold its own
`components/`, `hooks/`, `store/`, `types.ts`, `utils.ts`, `constants.ts`, `__tests__/`.
Nest as deep as ownership actually goes; don't flatten a component that owns children.
- **Shell vs no shell.** A page with tabs gets a shell `index.tsx` whose only job is tab
switching, plus one folder per view. A page without tabs is just the feature folder.
- **Tests.** Feature-root tests in `__tests__/`; a component's tests next to the component
(its own `__tests__/`). Never reach across features in a test.
- **No barrel files.** A page's `index.tsx` is the route entry (a component), not a
re-export hub. Import components by their own path.
- **File size.** Split past ~300 LOC: extract components, and behaviour into
`use<Component>Callbacks`-style hooks. More than ~3 type declarations in a file means a
`types.ts`, and more than ~3 in `types.ts` means a `types/` folder.
- **Styling.** CSS Modules (`<Name>.module.scss`) next to the component — see
`docs/css-modules-guide.md`. Semantic tokens only.
- **State.** Server → React Query (prefer `api/generated` hooks); URL → nuqs; client →
Zustand, one store per file, always with a selector. No Redux or Context for new code.
## Migrating existing code
Most feature code still lives in `src/container` and `src/modules`, with a thin wrapper in
`src/pages`. When touching one of those features:
1. Scaffold the target with `pnpm scaffold page <Name>` (see `../SKILL.md`).
2. Move files in, one concern per commit — components, then hooks, then state.
3. Update importers; keep `src/container/<Feature>` deleted, not re-exported. A shim
directory is how the old layout survives.
4. Do the dead-code pass first: unused props, exports, imports and debug logs go before the
move, in their own commit.

View File

@@ -1,606 +0,0 @@
#!/usr/bin/env node
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
routeKey,
routeSpec,
substitute,
toCamel,
toDirName,
toKebab,
toTitle,
tokensFor,
} from './lib.mjs';
const SKILL_DIR = dirname(fileURLToPath(import.meta.url));
const TEMPLATES = join(SKILL_DIR, 'templates');
const FRONTEND = resolve(SKILL_DIR, '..', '..', '..');
const SRC = join(FRONTEND, 'src');
const ROUTE_FILES = {
routes: join(SRC, 'constants', 'routes.ts'),
permission: join(SRC, 'utils', 'permission', 'index.ts'),
pageComponents: join(SRC, 'AppRoutes', 'pageComponents.ts'),
appRoutes: join(SRC, 'AppRoutes', 'routes.ts'),
topNav: join(SRC, 'container', 'TopNav', 'DateTimeSelectionV2', 'constants.ts'),
};
const ROUTE_ROLES = "['ADMIN', 'EDITOR', 'VIEWER']";
// Port is fixed in vite.config.ts; the base path comes from VITE_BASE_PATH like vite does.
const DEV_SERVER_ORIGIN = 'http://localhost:3301';
function devServerUrl(path) {
const base = process.env.VITE_BASE_PATH ?? envFileValue('VITE_BASE_PATH') ?? '/';
return `${DEV_SERVER_ORIGIN}${base.replace(/\/+$/, '')}${path}`;
}
function envFileValue(name) {
const envFile = join(FRONTEND, '.env');
if (!existsSync(envFile)) {
return undefined;
}
const match = readFileSync(envFile, 'utf8').match(
new RegExp(`^\\s*${name}\\s*=\\s*["']?([^"'\\n#]*)`, 'm'),
);
return match?.[1].trim() || undefined;
}
// Created empty, so the folder exists before it has a file to justify it.
const FEATURE_DIRS = ['components', 'hooks', 'store'];
const USAGE = `usage:
pnpm scaffold page <Name> [--views A,B,C] [--no-tests] [--dry-run] [--force]
pnpm scaffold component <Name> [--parent <path>] [--full] [--no-tests] [--dry-run] [--force]
examples:
pnpm scaffold page ApiMonitoring
pnpm scaffold page Traces --views Explorer,Funnels,Views
pnpm scaffold page Traces/Explorer
pnpm scaffold component DataTable
pnpm scaffold component QueryBar --parent pages/Traces/Explorer`;
function fail(message) {
process.stderr.write(`error: ${message}\n\n${USAGE}\n`);
process.exit(1);
}
function expandEquals(argv) {
return argv.flatMap((arg) =>
arg.startsWith('--') && arg.includes('=')
? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)]
: [arg],
);
}
function parseArgs(argv) {
const flags = {
views: [],
parent: 'components',
full: false,
tests: true,
dryRun: false,
force: false,
};
const positional = [];
const provided = new Set();
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
provided.add(arg);
if (arg === '--views' || arg === '--parent') {
const value = argv[i + 1];
if (!value || value.startsWith('--')) {
fail(`${arg} needs a value`);
}
if (arg === '--views') {
flags.views = value
.split(',')
.map((view) => view.trim())
.filter(Boolean);
if (!flags.views.length) {
fail('--views needs at least one name');
}
} else {
flags.parent = value;
}
i += 1;
} else if (arg === '--full') {
flags.full = true;
} else if (arg === '--no-tests') {
flags.tests = false;
} else if (arg === '--dry-run') {
flags.dryRun = true;
} else if (arg === '--force') {
flags.force = true;
} else if (arg === '-h' || arg === '--help') {
process.stdout.write(`${USAGE}\n`);
process.exit(0);
} else if (arg.startsWith('-')) {
fail(`unknown option: ${arg}`);
} else {
positional.push(arg);
}
}
return { positional, flags, provided };
}
const created = [];
const skipped = [];
let targetExisted = false;
let pagePath = '';
function writeFile(target, contents, flags) {
const rel = relative(FRONTEND, target);
if (existsSync(target) && !flags.force) {
skipped.push(rel);
return;
}
if (!flags.dryRun) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, contents);
}
created.push(rel);
}
function createDirs(targetDir, dirs, flags) {
for (const dir of dirs) {
const target = join(targetDir, dir);
const rel = `${relative(FRONTEND, target)}/`;
if (existsSync(target)) {
skipped.push(rel);
continue;
}
if (!flags.dryRun) {
mkdirSync(target, { recursive: true });
}
created.push(rel);
}
}
// Template files carry a `.tmpl` suffix so no TypeScript, lint or editor tooling
// treats them as source; the suffix is dropped on the way out.
function renderTree(templateDir, targetDir, tokens, flags) {
for (const entry of readdirSync(templateDir).sort()) {
const from = join(templateDir, entry);
const name = substitute(entry.replace(/\.tmpl$/, ''), tokens);
if (statSync(from).isDirectory()) {
if (!flags.tests && name === '__tests__') {
continue;
}
renderTree(from, join(targetDir, name), tokens, flags);
} else {
writeFile(
join(targetDir, name),
substitute(readFileSync(from, 'utf8'), tokens),
flags,
);
}
}
}
// Icons for tab names the product already uses; anything else gets a neutral one.
const TAB_ICONS = {
Explorer: 'Compass',
Funnels: 'Cone',
Pipelines: 'Workflow',
SavedViews: 'TowerControl',
Views: 'TowerControl',
};
const DEFAULT_TAB_ICON = 'LayoutPanelTop';
const tabIcon = (view) => TAB_ICONS[toDirName(view)] ?? DEFAULT_TAB_ICON;
const tabName = (view) => `${toCamel(view)}Tab`;
function shellTokens(segments, views) {
const icons = [...new Set(views.map(tabIcon))].sort((a, b) => a.localeCompare(b));
const viewImports = views
.map((view) => `import ${toDirName(view)} from './${toDirName(view)}';`)
.join('\n');
const tabExports = views
.map((view) => {
const route = `ROUTES.${routeKey(segments, view)}`;
return [
`export const ${tabName(view)}: TabRoutes = {`,
`\tComponent: ${toDirName(view)},`,
'\tname: (',
'\t\t<div className={styles.tabItem}>',
`\t\t\t<${tabIcon(view)} size={16} /> ${toTitle(view)}`,
'\t\t</div>',
'\t),',
`\troute: ${route},`,
`\tkey: ${route},`,
'};',
].join('\n');
})
.join('\n\n');
const tabAssertions = views
.map(
(view) =>
`\t\texpect(screen.getByRole('tab', { name: '${toTitle(view)}' })).toBeInTheDocument();\n`,
)
.join('');
return {
__ICON_IMPORTS__: `import { ${icons.join(', ')} } from '@signozhq/icons';`,
__VIEW_IMPORTS__: viewImports,
__TAB_EXPORTS__: `${tabExports}\n`,
__TAB_NAMES__: views.map(tabName).join(', '),
__BASE_ROUTE__: `ROUTES.${routeKey(segments)}_BASE`,
__FIRST_TAB__: tabName(views[0]),
__FIRST_VIEW_TESTID__: `${toKebab(views[0])}-page`,
__TAB_ASSERTIONS__: tabAssertions,
};
}
const edited = [];
function insertBefore(source, anchor, text, rel, from = 0) {
const index = source.indexOf(anchor, from);
if (index === -1) {
fail(`could not find \`${anchor.trim()}\` in ${rel}`);
}
return source.slice(0, index) + text + source.slice(index);
}
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// An existing key or export is only reused when it already means what the generator
// would have written; anything else is a naming collision and stops the run before
// any shared file is touched.
function assertSame(rel, what, existing, expected) {
if (existing !== expected) {
fail(
`${what} already exists in ${rel} as ${existing}, expected ${expected}` +
'pick another name',
);
}
}
function planRoutes({ component, keys, routed }) {
return [
{
file: ROUTE_FILES.routes,
transform: (source, rel) => {
const added = keys.filter(({ key, path }) => {
const match = source.match(new RegExp(`\\n\\t${key}: '([^']*)',`));
if (match) {
assertSame(rel, `ROUTES.${key}`, `'${match[1]}'`, `'${path}'`);
}
return !match;
});
const text = added.map(({ key, path }) => `\n\t${key}: '${path}',`).join('');
return {
source: insertBefore(source, '\n} as const;', text, rel),
added: added.map(({ key }) => key),
};
},
},
{
file: ROUTE_FILES.permission,
transform: (source, rel) => {
const start = source.indexOf('export const routePermission');
if (start === -1) {
fail(`could not find \`routePermission\` in ${rel}`);
}
const added = keys
.map(({ key }) => key)
.filter((key) => !source.includes(`\n\t${key}: `));
const text = added.map((key) => `\n\t${key}: ${ROUTE_ROLES},`).join('');
return { source: insertBefore(source, '\n};', text, rel, start), added };
},
},
{
file: ROUTE_FILES.pageComponents,
transform: (source, rel) => {
const existing = source.match(
new RegExp(`export const ${component.name} = Loadable\\([\\s\\S]*?'([^']+)'`),
);
if (existing) {
assertSame(rel, component.name, `'${existing[1]}'`, `'${component.importPath}'`);
return { source, added: [] };
}
const text =
`\nexport const ${component.name} = Loadable(\n` +
`\t() => import(/* webpackChunkName: "${component.chunk}" */ '${component.importPath}'),\n);\n`;
return {
source: source.replace(/\n*$/, '\n') + text,
added: [component.name],
};
},
},
{
file: ROUTE_FILES.appRoutes,
transform: (source, rel) => {
const added = [];
let next = source;
const importEnd = next.indexOf("} from './pageComponents';");
const importStart = next.lastIndexOf('import {', importEnd);
if (importEnd === -1 || importStart === -1) {
fail(`could not find the pageComponents import in ${rel}`);
}
const names = next
.slice(importStart + 'import {'.length, importEnd)
.split(',')
.map((name) => name.trim())
.filter(Boolean);
if (!names.includes(component.name)) {
const lower = component.name.toLowerCase();
const at = names.findIndex((name) => name.toLowerCase() > lower);
names.splice(at === -1 ? names.length : at, 0, component.name);
next =
next.slice(0, importStart) +
`import {\n\t${names.join(',\n\t')},\n` +
next.slice(importEnd);
added.push(`import ${component.name}`);
}
const arrayStart = next.indexOf('const routes: AppRoutes[] = [');
if (arrayStart === -1) {
fail(`could not find \`const routes: AppRoutes[]\` in ${rel}`);
}
const missing = routed.filter((key) => {
const match = next.match(
new RegExp(`component: (\\w+),\\n\\t\\tkey: '${escapeRegExp(key)}',`),
);
if (match) {
assertSame(rel, `route ${key}`, match[1], component.name);
}
return !match;
});
const entries = missing
.map((key) =>
[
'\n\t{',
`\t\tpath: ROUTES.${key},`,
'\t\texact: true,',
`\t\tcomponent: ${component.name},`,
`\t\tkey: '${key}',`,
'\t\tisPrivate: true,',
'\t},',
].join('\n'),
)
.join('');
next = insertBefore(next, '\n];', entries, rel, arrayStart);
added.push(...missing);
return { source: next, added };
},
},
{
file: ROUTE_FILES.topNav,
transform: (source, rel) => {
const start = source.indexOf('export const routesToSkip = [');
if (start === -1) {
fail(`could not find \`routesToSkip\` in ${rel}`);
}
const end = source.indexOf('\n];', start);
const block = source.slice(start, end);
const added = routed.filter((key) => !block.includes(`ROUTES.${key},`));
const text = added.map((key) => `\n\tROUTES.${key},`).join('');
return { source: insertBefore(source, '\n];', text, rel, start), added };
},
},
];
}
// Every shared file is read and validated before any is written, so a failed anchor or
// a naming collision leaves the tree untouched.
function planRouteEdits(spec) {
return planRoutes(spec).map(({ file, transform }) => {
const rel = relative(FRONTEND, file);
if (!existsSync(file)) {
fail(`shared file not found: ${rel}`);
}
const { source, added } = transform(readFileSync(file, 'utf8'), rel);
return { file, rel, source, added };
});
}
function commitRouteEdits(pending, flags) {
for (const { file, rel, source, added } of pending) {
if (!added.length) {
continue;
}
if (!flags.dryRun) {
writeFileSync(file, source);
}
edited.push({ rel, added });
}
}
function scaffoldFeature(targetDir, name, flags) {
renderTree(join(TEMPLATES, 'feature'), targetDir, tokensFor(name), flags);
createDirs(targetDir, FEATURE_DIRS, flags);
}
function scaffoldPage(name, flags) {
const segments = name.split('/').filter(Boolean).map(toDirName);
if (!segments.length) {
fail('page needs a name');
}
const viewDirs = flags.views.map(toDirName);
const duplicate = viewDirs.find((dir, index) => viewDirs.indexOf(dir) !== index);
if (duplicate) {
fail(`duplicate view: ${duplicate}`);
}
const targetDir = join(SRC, 'pages', ...segments);
const leaf = segments[segments.length - 1];
targetExisted = existsSync(targetDir);
// Shared files land before the page folder so a watching type-checker never sees a
// page that references ROUTES keys that do not exist yet.
const spec = routeSpec(segments, flags.views);
commitRouteEdits(planRouteEdits(spec), flags);
pagePath = spec.keys[0].path;
if (flags.views.length) {
renderTree(
join(TEMPLATES, 'shell'),
targetDir,
{ ...tokensFor(leaf), ...shellTokens(segments, flags.views) },
flags,
);
for (const view of flags.views) {
scaffoldFeature(join(targetDir, toDirName(view)), view, flags);
}
} else {
scaffoldFeature(targetDir, leaf, flags);
}
return targetDir;
}
function resolveParent(parent) {
const segments = parent
.replace(/^src\//, '')
.replace(/\/components\/?$/, '')
.split('/')
.filter(Boolean);
if (segments[0] === 'pages') {
return ['pages', ...segments.slice(1).map(toDirName)];
}
return segments;
}
function scaffoldComponent(name, flags) {
const tokens = tokensFor(name);
const parent = resolveParent(flags.parent);
const isGlobal = parent.length === 1 && parent[0] === 'components';
const componentsDir = isGlobal
? join(SRC, 'components')
: join(SRC, ...parent, 'components');
if (relative(SRC, componentsDir).startsWith('..')) {
fail(`--parent must stay inside src: ${flags.parent}`);
}
if (parent[0] === 'pages' && parent.length < 2) {
fail('a component under pages/ needs a feature: --parent pages/<Feature>');
}
if (!isGlobal && !existsSync(join(SRC, ...parent))) {
fail(`parent does not exist: src/${parent.join('/')}`);
}
const targetDir = join(componentsDir, tokens.__Pascal__);
targetExisted = existsSync(targetDir);
renderTree(join(TEMPLATES, 'component'), targetDir, tokens, flags);
if (flags.full) {
renderTree(join(TEMPLATES, 'component-extras'), targetDir, tokens, flags);
createDirs(targetDir, FEATURE_DIRS, flags);
}
return targetDir;
}
function report(kind, targetDir, flags) {
const rel = relative(FRONTEND, targetDir);
const verb = flags.dryRun ? 'would create' : 'created';
const segments = rel.split('/').slice(2);
const isNestedView = kind === 'page' && segments.length > 1 && !flags.views.length;
const leafName = segments[segments.length - 1];
if (targetExisted) {
process.stdout.write(
`\nwarning: ${rel} already existed — only missing entries were added\n`,
);
}
process.stdout.write(`\n${verb} ${created.length} entr(ies) in ${rel}\n`);
for (const entry of created) {
process.stdout.write(` + ${entry}\n`);
}
if (skipped.length) {
process.stdout.write(
`\nskipped ${skipped.length} existing entr(ies) — pass --force to overwrite files\n`,
);
for (const entry of skipped) {
process.stdout.write(` = ${entry}\n`);
}
}
if (edited.length) {
const editVerb = flags.dryRun ? 'would edit' : 'edited';
process.stdout.write(`\n${editVerb} ${edited.length} shared file(s)\n`);
for (const { rel, added } of edited) {
const additions = added.map((entry) => `+${entry}`).join(', ');
process.stdout.write(` ~ ${rel}: ${additions}\n`);
}
}
const steps =
kind === 'page'
? [
'review the route registration (constants/routes.ts, utils/permission, AppRoutes/pageComponents.ts, AppRoutes/routes.ts, TopNav routesToSkip) and add a SideNav entry in container/SideNav/menuItems.tsx if the page needs one',
...(isNestedView
? [
`add a tab export for ${leafName} in the shell's constants.tsx and include it in the routes array in the shell's index.tsx`,
]
: []),
'delete the placeholders you do not need (empty types/utils/constants, unused folders)',
'fill in README.md',
`verify: pnpm tsgo --noEmit && pnpm oxlint ${rel} && pnpm jest ${rel}`,
]
: [
'delete the placeholders you do not need (empty types/utils/constants, unused folders)',
`verify: pnpm tsgo --noEmit && pnpm oxlint ${rel} && pnpm jest ${rel}`,
];
if (pagePath) {
process.stdout.write(`\nopen: ${devServerUrl(pagePath)}\n`);
}
process.stdout.write('\nnext:\n');
steps.forEach((step, index) => {
process.stdout.write(` ${index + 1}. ${step}\n`);
});
process.stdout.write(
'\nnote: git does not track empty folders — components/, hooks/ and store/ only\n' +
'show up in a commit once they hold a file.\n',
);
}
const { positional, flags, provided } = parseArgs(expandEquals(process.argv.slice(2)));
const [kind, name] = positional;
if (!kind || !name) {
fail('a command and a name are required');
}
if (positional.length > 2) {
fail(`unexpected argument: ${positional[2]}`);
}
function rejectFlags(unsupported) {
for (const flag of unsupported) {
if (provided.has(flag)) {
fail(`${flag} does not apply to \`${kind}\``);
}
}
}
let targetDir;
try {
if (kind === 'page') {
rejectFlags(['--parent', '--full']);
targetDir = scaffoldPage(name, flags);
} else if (kind === 'component') {
rejectFlags(['--views']);
targetDir = scaffoldComponent(name, flags);
} else {
fail(`unknown command: ${kind}`);
}
} catch (error) {
fail(error.message);
}
report(kind, targetDir, flags);

View File

@@ -1,95 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
routeKey,
routePath,
routeSpec,
substitute,
toCamel,
toConst,
toDirName,
toKebab,
toTitle,
tokensFor,
} from './lib.mjs';
describe('names', () => {
it('keeps typed casing and forces the first letter up', () => {
assert.equal(toDirName('LLMObservability'), 'LLMObservability');
assert.equal(toDirName('apiMonitoring'), 'ApiMonitoring');
});
it('collapses separated names to PascalCase', () => {
assert.equal(toDirName('api-monitoring'), 'ApiMonitoring');
assert.equal(toDirName('api monitoring'), 'ApiMonitoring');
assert.equal(toDirName('saved_views'), 'SavedViews');
});
it('derives kebab, title, const and camel forms, splitting acronyms', () => {
assert.deepEqual(tokensFor('LLMObservability'), {
__Pascal__: 'LLMObservability',
__kebab__: 'llm-observability',
__camel__: 'lLMObservability',
__CONST__: 'LLM_OBSERVABILITY',
__Title__: 'LLM Observability',
});
assert.equal(toKebab('SavedViews'), 'saved-views');
assert.equal(toTitle('SavedViews'), 'Saved Views');
assert.equal(toConst('SavedViews'), 'SAVED_VIEWS');
assert.equal(toCamel('SavedViews'), 'savedViews');
});
it('rejects names with no usable characters', () => {
assert.throws(() => toDirName('***'), /no usable name characters/);
});
});
describe('substitute', () => {
it('replaces every occurrence of every token, in file names and contents', () => {
const tokens = tokensFor('ApiMonitoring');
assert.equal(substitute('__Pascal__.module.scss', tokens), 'ApiMonitoring.module.scss');
assert.equal(
substitute('__kebab__-page / __kebab__-shell / __Title__', tokens),
'api-monitoring-page / api-monitoring-shell / Api Monitoring',
);
});
});
describe('routes', () => {
it('builds keys and paths from every segment plus the view', () => {
assert.equal(routeKey(['Traces'], 'SavedViews'), 'TRACES_SAVED_VIEWS');
assert.equal(routePath(['Traces'], 'SavedViews'), '/traces/saved-views');
assert.equal(routeKey(['Traces', 'Explorer']), 'TRACES_EXPLORER');
assert.equal(routePath(['Traces', 'Explorer']), '/traces/explorer');
});
it('routes a leaf page under a single key', () => {
assert.deepEqual(routeSpec(['ApiMonitoring'], []), {
component: {
name: 'ApiMonitoringPage',
importPath: 'pages/ApiMonitoring',
chunk: 'Api Monitoring Page',
},
keys: [{ key: 'API_MONITORING', path: '/api-monitoring' }],
routed: ['API_MONITORING'],
});
});
it('routes a shell under a base key plus one key per view, all to the shell', () => {
const spec = routeSpec(['Traces'], ['Explorer', 'Funnels']);
assert.equal(spec.component.name, 'TracesPage');
assert.deepEqual(spec.keys, [
{ key: 'TRACES_BASE', path: '/traces' },
{ key: 'TRACES_EXPLORER', path: '/traces/explorer' },
{ key: 'TRACES_FUNNELS', path: '/traces/funnels' },
]);
assert.deepEqual(spec.routed, ['TRACES_BASE', 'TRACES_EXPLORER', 'TRACES_FUNNELS']);
});
it('points a view added under an existing shell at the shell component', () => {
const spec = routeSpec(['Traces', 'Explorer'], []);
assert.equal(spec.component.importPath, 'pages/Traces');
assert.deepEqual(spec.keys, [{ key: 'TRACES_EXPLORER', path: '/traces/explorer' }]);
});
});

View File

@@ -1,21 +0,0 @@
# __Pascal__
<!-- What this component renders, and the features that use it. -->
## API
<!-- Props, and the behaviour each one controls. -->
## Structure
| Path | Purpose |
| --- | --- |
| `__Pascal__.tsx` | The component. |
| `__Pascal__.module.scss` | Styles. |
| `components/` | Child components this one owns. |
| `hooks/` | Behaviour extracted out of the component. |
| `store/` | Zustand stores this component owns. |
| `types.ts` | Types shared inside this folder. |
| `utils.ts` | Pure helpers. |
| `constants.ts` | Constants. |
| `__tests__/` | Tests. |

View File

@@ -1,4 +0,0 @@
.__camel__ {
display: flex;
color: var(--l1-foreground);
}

View File

@@ -1,7 +0,0 @@
import styles from './__Pascal__.module.scss';
function __Pascal__(): JSX.Element {
return <div className={styles.__camel__} data-testid="__kebab__" />;
}
export default __Pascal__;

View File

@@ -1,11 +0,0 @@
import { render, screen } from 'tests/test-utils';
import __Pascal__ from '../__Pascal__';
describe('__Pascal__', () => {
it('renders', () => {
render(<__Pascal__ />);
expect(screen.getByTestId('__kebab__')).toBeInTheDocument();
});
});

View File

@@ -1,28 +0,0 @@
# __Title__
<!-- One paragraph: what this feature does, who uses it, and where it is reachable from. -->
## Structure
| Path | Purpose |
| --- | --- |
| `index.tsx` | Feature entry. Composition only — no business logic. |
| `components/` | Feature-local components, nested as `components/<Name>/`. |
| `hooks/` | Feature hooks, including React Query wrappers over `api/generated`. |
| `store/` | Zustand stores for feature-local client state. |
| `types.ts` | Shared feature types. Split into `types/` past ~3 declarations. |
| `utils.ts` | Pure helpers. |
| `constants.ts` | Feature constants. |
| `__tests__/` | Feature-root tests. Component tests live with the component. |
## Data
<!-- Endpoints this feature reads/writes, and the hooks that wrap them. -->
## State
<!-- What lives in the URL (nuqs), what lives in React Query, what lives in store/. -->
## Routing
<!-- Route key in constants/routes.ts, lazy import in AppRoutes/pageComponents.ts, entry in AppRoutes/routes.ts. -->

View File

@@ -1,12 +0,0 @@
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-4);
background: var(--l1-background);
}
.title {
color: var(--l1-foreground);
font-size: var(--font-size-lg);
}

View File

@@ -1,11 +0,0 @@
import { render, screen } from 'tests/test-utils';
import __Pascal__ from '../index';
describe('__Pascal__', () => {
it('renders the page', () => {
render(<__Pascal__ />);
expect(screen.getByTestId('__kebab__-page')).toBeInTheDocument();
});
});

View File

@@ -1,11 +0,0 @@
import styles from './__Pascal__.module.scss';
function __Pascal__(): JSX.Element {
return (
<section className={styles.container} data-testid="__kebab__-page">
<h1 className={styles.title}>__Title__</h1>
</section>
);
}
export default __Pascal__;

View File

@@ -1,20 +0,0 @@
# __Title__
<!-- One paragraph: what this section of the product is, and what each tab is for. -->
## Structure
| Path | Purpose |
| --- | --- |
| `index.tsx` | Shell. Tab switching only — no feature logic. |
| `constants.tsx` | One `TabRoutes` export per tab: icon, label, route and the view it renders. |
| `<View>/` | One folder per tab, each a self-contained feature. |
## Routing
Every path is registered in `src/constants/routes.ts`, `src/utils/permission/index.ts`,
`src/AppRoutes/routes.ts` and the `routesToSkip` list in
`src/container/TopNav/DateTimeSelectionV2/constants.ts`, all rendering this shell through the
lazy import in `src/AppRoutes/pageComponents.ts`. The base path redirects to the first tab;
`RouteTab` picks the tab from the current path. Adding a tab means a new `ROUTES` key, a
route entry, a permission entry, a `routesToSkip` entry and a `TabRoutes` export here.

View File

@@ -1,21 +0,0 @@
.shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:global(.ant-tabs-nav) {
padding: 0 var(--spacing-8);
margin-bottom: 0;
&::before {
border-bottom: 1px solid var(--l1-border) !important;
}
}
}
.tabItem {
display: flex;
align-items: center;
gap: var(--spacing-4);
}

View File

@@ -1,25 +0,0 @@
import { render, screen } from 'tests/test-utils';
import ROUTES from 'constants/routes';
import { __FIRST_TAB__ } from '../constants';
import __Pascal__ from '../index';
describe('__Pascal__', () => {
it('renders one tab per view', () => {
render(<__Pascal__ />, undefined, { initialRoute: __FIRST_TAB__.route });
expect(screen.getByTestId('__kebab__-shell')).toBeInTheDocument();
__TAB_ASSERTIONS__ });
it('renders the view for the active tab', () => {
render(<__Pascal__ />, undefined, { initialRoute: __FIRST_TAB__.route });
expect(screen.getByTestId('__FIRST_VIEW_TESTID__')).toBeInTheDocument();
});
it('redirects the base path to the first tab', () => {
render(<__Pascal__ />, undefined, { initialRoute: __BASE_ROUTE__ });
expect(screen.getByTestId('__FIRST_VIEW_TESTID__')).toBeInTheDocument();
});
});

View File

@@ -1,9 +0,0 @@
import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
__ICON_IMPORTS__
__VIEW_IMPORTS__
import styles from './__Pascal__.module.scss';
__TAB_EXPORTS__

View File

@@ -1,32 +0,0 @@
import { matchPath, Redirect, useLocation } from 'react-router-dom';
import RouteTab from 'components/RouteTab';
import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { __TAB_NAMES__ } from './constants';
import styles from './__Pascal__.module.scss';
function __Pascal__(): JSX.Element {
const { pathname } = useLocation();
const routes: TabRoutes[] = [__TAB_NAMES__];
if (matchPath(pathname, { path: __BASE_ROUTE__, exact: true })) {
return <Redirect to={routes[0].route} />;
}
return (
<div className={styles.shell} data-testid="__kebab__-shell">
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
showRightSection={false}
/>
</div>
);
}
export default __Pascal__;

View File

@@ -10,7 +10,6 @@
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build -o storybook-static",
"test:storybook": "bash scripts/test-storybook.sh",
"scaffold": "node .claude/skills/scaffold-feature/scaffold.mjs",
"build": "vite build",
"preview": "vite preview",
"prettify": "oxfmt",

View File

@@ -150,7 +150,7 @@ export const invalidateListLLMPricingRules = async (
};
/**
* Single write endpoint used by both the user and the Zeus sync job. Rules without isOverride are matched by sourceId and override rows (is_override=true) are skipped. Rules with isOverride are matched by id and inserted when new.
* Single write endpoint used by both the user and the Zeus sync job. Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true) are fully preserved when the request does not provide isOverride; only synced_at is stamped.
* @summary Create or update pricing rules
*/
export const createOrUpdateLLMPricingRules = (

View File

@@ -2,6 +2,8 @@
display: flex;
flex-direction: row;
position: relative;
flex: 1;
min-height: 0;
.quick-filters-settings-container {
flex: 0 0 0;

View File

@@ -0,0 +1,33 @@
// The one `overflow: hidden` in the chain. Ancestors (RouteTab, AppLayout)
// only hand height down; each pane below owns its own scroll.
.layout {
display: flex;
flex: 1;
height: 100%;
min-height: 0;
overflow: hidden;
}
// Positioned so overlays (settings drawer) paint above the content pane
// without changing this pane's layout width.
.filters {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
position: relative;
overflow: visible;
z-index: 2;
}
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
// `height: 100%`), which owns the scrolling.
.content {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}

View File

@@ -0,0 +1,54 @@
import { ComponentProps, ReactNode } from 'react';
import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuickFilters from '../QuickFilters';
import styles from './QuickFiltersLayout.module.scss';
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
typeof QuickFilters,
ComponentProps<typeof QuickFilters>
>;
export interface QuickFiltersLayoutProps {
quickFilterProps: QuickFiltersElementProps;
showFilters: boolean;
className?: string;
contentClassName?: string;
testId?: string;
children: ReactNode;
}
function QuickFiltersLayout({
quickFilterProps,
showFilters,
className,
contentClassName,
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{showFilters && (
<aside
className={styles.filters}
data-testid="quick-filters-layout-filters"
>
<QuickFilters {...quickFilterProps} />
</aside>
)}
<section
className={cx(styles.content, contentClassName)}
data-testid="quick-filters-layout-content"
>
<OverlayScrollbar>
<div>{children}</div>
</OverlayScrollbar>
</section>
</div>
);
}
export default QuickFiltersLayout;

View File

@@ -0,0 +1,79 @@
import { render, screen } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
jest.mock('../QuickFiltersLayout.module.scss', () => ({
__esModule: true,
default: {
layout: 'layout',
filters: 'filters',
content: 'content',
},
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="quick-filters">{source}</div>
),
}));
const quickFilterProps = {
source: QuickFiltersSource.TRACES_EXPLORER,
handleFilterVisibilityChange: jest.fn(),
};
describe('QuickFiltersLayout', () => {
it('renders QuickFilters with the given props inside the filters pane', () => {
render(
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
<div>content</div>
</QuickFiltersLayout>,
);
const filtersPane = screen.getByTestId('quick-filters-layout-filters');
expect(filtersPane).toContainElement(screen.getByTestId('quick-filters'));
expect(screen.getByTestId('quick-filters')).toHaveTextContent(
QuickFiltersSource.TRACES_EXPLORER,
);
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
'content',
);
});
it('does not render the filters pane when showFilters is false', () => {
render(
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
<div>content</div>
</QuickFiltersLayout>,
);
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
});
it('merges classNames onto the root and content panes', () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
className="page-root"
contentClassName="page-content"
testId="page"
>
<div>content</div>
</QuickFiltersLayout>,
);
const root = screen.getByTestId('page');
expect(root).toHaveClass('layout', 'page-root');
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
'content',
'page-content',
);
});
});

View File

@@ -6,27 +6,12 @@
left: 0;
z-index: 999;
width: 342px;
height: 100%;
background: var(--l1-background);
transition: width 0.05s ease-in-out;
overflow: hidden;
color: var(--l1-foreground);
&.qf-logs-explorer {
height: calc(100vh - 45px);
}
&.qf-exceptions {
height: 100vh;
}
&.qf-api-monitoring {
height: calc(100vh - 45px);
}
&.qf-traces-explorer {
height: calc(100vh - 45px);
}
&.hidden {
width: 0;
}

View File

@@ -0,0 +1,38 @@
// Hands the parent's height down to the active pane and lets the pane scroll
// its own content, so TopNav and the tab bar stay put. Child combinators only
// (nested Tabs must not be caught).
.routeTab {
flex: 1;
min-height: 0;
}
.routeTab > :global(.ant-tabs-content-holder) {
display: flex;
flex-direction: column;
}
.routeTab > :global(.ant-tabs-content-holder) > :global(.ant-tabs-content) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.routeTab
> :global(.ant-tabs-content-holder)
> :global(.ant-tabs-content)
> :global(.ant-tabs-tabpane-active) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.routeTab
> :global(.ant-tabs-content-holder)
> :global(.ant-tabs-content)
> :global(.ant-tabs-tabpane-active)
> :global(.overlay-scrollbar) {
flex: 1;
min-height: 0;
}

View File

@@ -5,6 +5,11 @@ import { fireEvent, render, screen } from 'tests/test-utils';
import RouteTab from './index';
import { RouteTabProps } from './types';
jest.mock('./RouteTab.module.scss', () => ({
__esModule: true,
default: { routeTab: 'routeTab' },
}));
function DummyComponent1(): JSX.Element {
return <div>Dummy Component 1</div>;
}
@@ -74,6 +79,36 @@ describe('RouteTab component', () => {
expect(history.location.pathname).toBe('/tab2');
});
it('applies the layout class alongside a custom className', () => {
const history = createMemoryHistory();
const { container } = render(
<Router history={history}>
<RouteTab
history={history}
routes={testRoutes}
activeKey="Tab1"
className="custom-tabs"
/>
</Router>,
);
expect(container.querySelector('.ant-tabs')).toHaveClass(
'routeTab',
'custom-tabs',
);
});
it('renders the active tab content inside an overlay scrollbar', () => {
const history = createMemoryHistory();
const { container } = render(
<Router history={history}>
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
</Router>,
);
expect(
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
).toHaveTextContent('Dummy Component 1');
});
it('calls onChangeHandler on tab change', () => {
const onChangeHandler = jest.fn();
const history = createMemoryHistory();

View File

@@ -5,20 +5,32 @@ import {
useParams,
} from 'react-router-dom';
import { Tabs, TabsProps } from 'antd';
import cx from 'classnames';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { RouteTabProps } from './types';
import styles from './RouteTab.module.scss';
interface Params {
[key: string]: string;
}
/**
* Each pane scrolls its own content inside an OverlayScrollbar, so the tab bar
* stays put. Mounted as the page root the pane is bounded to the viewport; inside
* a plain block wrapper the scroller is inert and the page scrolls as usual.
* Pane content that needs a bounded box must size itself with `height: 100%`
* (the scroller's viewport is block flow, so `flex: 1` has no effect there).
*/
function RouteTab({
routes,
activeKey,
onChangeHandler,
history,
showRightSection,
className,
...rest
}: RouteTabProps & TabsProps): JSX.Element {
const params = useParams<Params>();
@@ -50,11 +62,16 @@ function RouteTab({
label: name,
key,
tabKey: route,
children: <Component />,
children: (
<OverlayScrollbar>
<Component />
</OverlayScrollbar>
),
}));
return (
<Tabs
className={cx(styles.routeTab, className)}
onChange={onChange}
destroyInactiveTabPane
activeKey={currentRoute?.key || activeKey}

View File

@@ -1,23 +1,15 @@
.api-monitoring-page {
display: flex;
height: 100%;
.api-monitoring-explorer {
.api-quick-filters-header {
padding: 12px;
border-bottom: 1px solid var(--l1-border);
border-right: 1px solid var(--l1-border);
.api-quick-filter-left-section {
width: 0%;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
.api-quick-filters-header {
padding: 12px;
border-bottom: 1px solid var(--l1-border);
border-right: 1px solid var(--l1-border);
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
line-height: 18px;
}
font-size: 14px;
line-height: 18px;
}
.api-module-right-section {
@@ -161,16 +153,6 @@
}
}
}
&.filter-visible {
.api-quick-filter-left-section {
width: 260px;
}
.api-module-right-section {
width: calc(100% - 260px);
}
}
}
.no-filtered-domains-message-container {

View File

@@ -1,8 +1,7 @@
import { useEffect } from 'react';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
@@ -20,20 +19,21 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
<section className="api-quick-filter-left-section">
<QuickFilters
className="qf-api-monitoring"
source={QuickFiltersSource.API_MONITORING}
signal={SignalType.API_MONITORING}
showFilterCollapse={false}
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
useFieldApis={quickFilterFieldApis}
/>
</section>
<QuickFiltersLayout
className="api-monitoring-explorer"
showFilters
quickFilterProps={{
className: 'qf-api-monitoring',
source: QuickFiltersSource.API_MONITORING,
signal: SignalType.API_MONITORING,
showFilterCollapse: false,
showQueryName: false,
handleFilterVisibilityChange: (): void => {},
useFieldApis: quickFilterFieldApis,
}}
>
<DomainList />
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -1,6 +1,7 @@
.tableWrapper {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.toolbar {

View File

@@ -2,10 +2,11 @@
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-2) var(--spacing-8);
:global(.ant-tabs-tabpane) {
padding: var(--spacing-0) var(--spacing-8);
}
--tabs-content-padding: 0;
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
}
.pageError {

View File

@@ -1,8 +1,9 @@
import { useCallback } from 'react';
import { Tabs } from 'antd';
import { Divider } from '@signozhq/ui/divider';
import { Tabs } from '@signozhq/ui/tabs';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import AttributeMappingActions from './components/AttributeMappingActions/AttributeMappingActions';
import AttributeMappingHeader from './components/AttributeMappingHeader/AttributeMappingHeader';
import AttributeMappingsTab from './AttributeMappingsTab/AttributeMappingsTab';
import DiscardChangesDialog from './components/DiscardChangesDialog/DiscardChangesDialog';
import GroupFormDrawer from './components/GroupFormDrawer/GroupFormDrawer';
@@ -58,23 +59,24 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
/>
{editor.saveError && (
<div className={styles.pageError} role="alert">
{editor.saveError}
</div>
)}
<Divider />
<Tabs
defaultActiveKey={MAPPINGS_TAB_KEY}
testId="attribute-mapping-tabs"
defaultValue={MAPPINGS_TAB_KEY}
items={tabItems}
tabBarExtraContent={
<AttributeMappingActions
isDirty={editor.isDirty}
isSaving={editor.isSaving}
onDiscard={discardConfirm.request}
onSave={editor.save}
/>
}
/>
{groupDrawer.isOpen && (
<GroupFormDrawer

View File

@@ -63,26 +63,6 @@ const EDITED_SPAN_JSON = `{
}
}`;
const SPAN_WITH_EXTRA_KEY_JSON = `{
"attributes": {
"input.value": "What is quantum computing?"
},
"resource": {
"service.name": "llm-gateway"
},
"demo": {
"name": "demo"
}
}`;
const EXTRA_KEY_RESULT_SPAN = {
attributes: {
'input.value': 'What is quantum computing?',
[MAPPED_ATTRIBUTE_KEY]: 'What is quantum computing?',
},
resource: { 'service.name': 'llm-gateway' },
};
const SPAN_INPUT_KEY = LOCALSTORAGE.LLM_ATTRIBUTE_MAPPING_TEST_SPAN;
describe('TestTab — sample-span flow', () => {
@@ -124,47 +104,6 @@ describe('TestTab — sample-span flow', () => {
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();
});
it('trims extra top-level keys and sends only the envelope', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let body: { spans?: { attributes?: Record<string, unknown> }[] } | undefined;
server.use(
rest.post(TEST_ENDPOINT, async (req, res, ctx) => {
body = await req.json();
return res(
ctx.status(200),
ctx.json(makeTestResponse([EXTRA_KEY_RESULT_SPAN])),
);
}),
);
render(<LLMObservabilityAttributeMapping />);
await user.click(screen.getByRole('tab', { name: 'Test' }));
const runBtn = await screen.findByTestId('run-test-button');
await user.clear(screen.getByTestId('monaco'));
await user.paste(SPAN_WITH_EXTRA_KEY_JSON);
await waitFor(() =>
expect(screen.getByTestId('monaco')).toHaveValue(SPAN_WITH_EXTRA_KEY_JSON),
);
expect(screen.queryByTestId('test-input-error')).not.toBeInTheDocument();
await user.click(runBtn);
await expect(
screen.findByTestId('test-results'),
).resolves.toBeInTheDocument();
expect(body?.spans?.[0]?.attributes).toStrictEqual({
'input.value': 'What is quantum computing?',
});
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
MAPPED_ATTRIBUTE_KEY,
);
expect(screen.getByTestId('test-result-0-resource')).toBeInTheDocument();
expect(screen.getByText('populated')).toBeInTheDocument();
});
it('surfaces a backend error and renders no results', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(

View File

@@ -1,51 +0,0 @@
import { parseSpanInput } from '../testPayload';
describe('parseSpanInput', () => {
it('reads the envelope and trims extra top-level keys', () => {
const span = parseSpanInput(`{
"attributes": { "llm.model_name": "gpt-4o" },
"resource": { "service.name": "llm-gateway" },
"demo": { "name": "demo" }
}`);
expect(span.attributes).toStrictEqual({ 'llm.model_name': 'gpt-4o' });
expect(span.resource).toStrictEqual({ 'service.name': 'llm-gateway' });
});
it('reads a clean envelope', () => {
const span = parseSpanInput(`{
"attributes": { "llm.model_name": "gpt-4o" },
"resource": { "service.name": "llm-gateway" }
}`);
expect(span.attributes).toStrictEqual({ 'llm.model_name': 'gpt-4o' });
expect(span.resource).toStrictEqual({ 'service.name': 'llm-gateway' });
});
it('treats an envelope-less object as a bare attribute map', () => {
const span = parseSpanInput('{ "llm.model_name": "gpt-4o", "demo": "x" }');
expect(span.attributes).toStrictEqual({
'llm.model_name': 'gpt-4o',
demo: 'x',
});
expect(span.resource).toStrictEqual({});
});
it('drops an envelope key that is not an object', () => {
const span = parseSpanInput(
'{ "attributes": { "llm.provider": "openai" }, "resource": "oops" }',
);
expect(span.attributes).toStrictEqual({ 'llm.provider': 'openai' });
expect(span.resource).toStrictEqual({});
});
it.each([
[' ', 'Paste a JSON span object to run the test.'],
['{ "a": }', 'Invalid JSON — check for trailing commas or missing quotes.'],
['[1, 2]', 'Span must be a JSON object of attribute key-value pairs.'],
])('rejects %p', (input, message) => {
expect(() => parseSpanInput(input)).toThrow(message);
});
});

View File

@@ -51,9 +51,13 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
// Any other top-level key (a real span carries name, spanId, kind...) is trimmed.
function isSpanEnvelope(parsed: Record<string, unknown>): boolean {
return isPlainObject(parsed.attributes) || isPlainObject(parsed.resource);
const keys = Object.keys(parsed);
return (
keys.length > 0 &&
keys.every((key) => key === 'attributes' || key === 'resource') &&
(isPlainObject(parsed.attributes) || isPlainObject(parsed.resource))
);
}
export function parseSpanInput(input: string): SpantypesSpanMapperTestSpanDTO {

View File

@@ -72,15 +72,20 @@ describe('LLMObservabilityAttributeMapping', () => {
const attributeMappingsTab = screen.getByRole('tab', {
name: 'Attribute Mappings',
});
expect(attributeMappingsTab).toHaveAttribute('aria-selected', 'true');
expect(attributeMappingsTab).toHaveAttribute('data-state', 'active');
await expect(
screen.findByTestId('attribute-mappings-tab'),
).resolves.toBeInTheDocument();
});
it('renders no Save/Discard while pristine', () => {
it('renders the header with its description and no Save/Discard while pristine', () => {
render(<LLMObservabilityAttributeMapping />);
expect(
screen.getByText(
'Configure source-to-target attribute remapping for LLM traces',
),
).toBeInTheDocument();
// The actions only appear once there are staged changes.
expect(screen.queryByTestId('save-changes-btn')).not.toBeInTheDocument();
expect(screen.queryByTestId('discard-changes-btn')).not.toBeInTheDocument();
@@ -119,11 +124,7 @@ describe('LLMObservabilityAttributeMapping', () => {
await user.click(screen.getByRole('tab', { name: 'Attribute Mappings' }));
await screen.findByTestId('attribute-mappings-tab');
// antd keeps a visited pane mounted and marks it aria-hidden, rather than
// unmounting it the way the previous tabs did.
expect(
screen.getByTestId('span-json-editor').closest('[role="tabpanel"]'),
).toHaveAttribute('aria-hidden', 'true');
expect(screen.queryByTestId('span-json-editor')).not.toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: 'Test' }));

View File

@@ -1,10 +0,0 @@
.actions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -1,53 +0,0 @@
import { Button } from '@signozhq/ui/button';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingActions.module.scss';
interface AttributeMappingActionsProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingActions({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingActionsProps): JSX.Element | null {
const canManage = useCanManageAttributeMapping();
if (!canManage || !isDirty) {
return null;
}
return (
<div className={styles.actions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
);
}
export default AttributeMappingActions;

View File

@@ -0,0 +1,18 @@
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-left: var(--spacing-2);
margin-top: var(--spacing-4);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -0,0 +1,56 @@
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { useCanManageAttributeMapping } from '../../hooks/useCanManageAttributeMapping';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
const canManage = useCanManageAttributeMapping();
return (
<header className={styles.pageHeader}>
<Typography.Text as="p" size="base" color="muted">
Configure source-to-target attribute remapping for LLM traces
</Typography.Text>
{canManage && isDirty && (
<div className={styles.pageHeaderActions}>
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
)}
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -1,7 +1,4 @@
.groupForm {
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-10);
@@ -21,8 +18,11 @@
}
.groupFormLabel {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.groupFormHint {

View File

@@ -5,12 +5,17 @@
}
.label {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.labelHint {
color: var(--l3-foreground);
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.keys {

View File

@@ -1,6 +1,4 @@
.form {
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
flex-direction: column;
gap: var(--spacing-10);
@@ -14,12 +12,17 @@
}
.label {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--l3-foreground);
}
.labelHint {
color: var(--l3-foreground);
font-weight: var(--font-weight-normal);
text-transform: none;
letter-spacing: normal;
}
.hint {

View File

@@ -65,8 +65,6 @@
}
.trace-explorer-page {
display: flex;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
@@ -75,32 +73,8 @@
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
width: 260px;
height: 100%;
min-height: 100vh;
border-right: 0px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
> .ant-card-body {
padding: 0;
width: 258px;
}
}
.trace-explorer {
width: 100%;
background: var(--l1-background);
> .ant-card-body {
padding: 0;
}
border-color: var(--l1-border);
}
.trace-explorer.filters-expanded {
width: calc(100% - 260px);
}
}

View File

@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -188,26 +186,21 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
<QuickFiltersLayout
className="trace-explorer-page"
data-testid="llm-observability-explorer"
testId="llm-observability-explorer"
showFilters={isOpen}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.AI_OBSERVABILITY,
signal: SignalType.AI_OBSERVABILITY,
useFieldApis: quickFiltersFieldApis,
handleFilterVisibilityChange: (): void => {
setOpen(!isOpen);
},
}}
>
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.AI_OBSERVABILITY}
signal={SignalType.AI_OBSERVABILITY}
useFieldApis={quickFiltersFieldApis}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,
})}
>
<div className="trace-explorer">
<div className="trace-explorer-header">
<Toolbar
showAutoRefresh
@@ -291,7 +284,7 @@ function Explorer(): JSX.Element {
)}
</div>
</div>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -11,12 +11,10 @@ const PERSISTED_KEY = `@signoz/table-columns/${STORAGE_KEY}`;
const ROWS = [{ id: 't1', trace_id: 'abc', 'service.name': 'checkout' }];
// An aggregate outside the default order starts hidden, so the persisted
// defaults are observable.
const COLUMNS = buildTraceViewColumns([
{ name: 'trace_id' },
{ name: 'service.name', fieldContext: 'resource' },
{ name: 'unlisted_aggregate' },
{ name: 'start_time' },
]);
function RaceHarness(): JSX.Element {
@@ -68,9 +66,7 @@ describe('TracesTable column-init race', () => {
await expect(screen.findByRole('table')).resolves.toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.queryByText('unlisted_aggregate')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'unlisted_aggregate',
]);
expect(screen.queryByText('start_time')).not.toBeInTheDocument();
expect(persistedState()?.hiddenColumnIds).toStrictEqual(['start_time']);
});
});

View File

@@ -128,7 +128,14 @@ describe('TracesView column persistence', () => {
await findTable();
await waitFor(() => {
expect(persistedState()?.hiddenColumnIds).toStrictEqual([]);
expect(persistedState()?.hiddenColumnIds).toStrictEqual([
'start_time',
'end_time',
'error_count',
'input',
'output',
'trace:tool_call_count:float64',
]);
});
expect(screen.getByText(OPTIONS_TRIGGER)).toBeInTheDocument();
expect(screen.getByText('llm_call_count')).toBeInTheDocument();
@@ -153,7 +160,7 @@ describe('TracesView column persistence', () => {
expect(screen.queryByText(OPTIONS_TRIGGER)).not.toBeInTheDocument();
});
it('renders the display-only columns when the field keys fail', async () => {
it('renders only the default-visible columns when the field keys fail', async () => {
mockFieldKeysFailure();
renderTracesView();
@@ -161,8 +168,8 @@ describe('TracesView column persistence', () => {
expect(screen.getByText('root_span_name')).toBeInTheDocument();
expect(screen.getByText('trace_id')).toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(screen.queryByText('llm_call_count')).not.toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(screen.queryByText('output')).not.toBeInTheDocument();
});
it('leaves an existing selection untouched while the field keys fail', async () => {

View File

@@ -109,24 +109,17 @@ describe('useTraceViewColumns', () => {
const { result } = await renderColumns();
expect(columnNames(result.current.columns)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'trace_id',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
...AGGREGATE_KEYS,
]);
});
@@ -134,24 +127,14 @@ describe('useTraceViewColumns', () => {
const { result } = await renderColumns();
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'trace_id',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
'total_tokens',
'estimated_total_cost',
]);
});
@@ -210,24 +193,14 @@ describe('useTraceViewColumns', () => {
expect(result.current.canPersistColumns).toBe(true);
expect(fieldNames(result.current.selectedFields)).toStrictEqual([
'trace_id',
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'trace_id',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
'total_tokens',
'estimated_total_cost',
]);
});
});

View File

@@ -5,43 +5,20 @@ import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
/** Always present: it is the row's link to the trace, but it can be reordered. */
/** Always visible: it is the row's link to the trace. */
export const TRACE_ID_COLUMN_ID = 'trace_id';
/** Fallback order, until the user drags a column; unlisted fields keep the order the keys endpoint returns them in. */
const DEFAULT_COLUMN_ORDER = [
TRACE_ID_COLUMN_ID,
/** Everything else starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set([
'service.name',
'root_span_name',
'estimated_total_cost',
'trace_duration_nano',
'span_count',
'total_tokens',
'input_tokens',
'output_tokens',
'distinct_tool_count',
'llm_call_count',
'tool_call_count',
'start_time',
'end_time',
'error_count',
'input',
'output',
'max_llm_duration_nano',
];
const orderRank = (field: TelemetryFieldKey): number => {
const index = DEFAULT_COLUMN_ORDER.indexOf(field.name);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
export const sortByDefaultOrder = (
fields: TelemetryFieldKey[],
): TelemetryFieldKey[] =>
[...fields].sort((a, b) => orderRank(a) - orderRank(b));
/** Anything the keys endpoint adds beyond the ordered set starts hidden; only applied at first init, since the store persists hidden ids. */
const DEFAULT_VISIBLE_FIELDS = new Set(DEFAULT_COLUMN_ORDER);
'total_tokens',
'estimated_total_cost',
TRACE_ID_COLUMN_ID,
]);
export const buildTraceViewColumns = (
fields: TelemetryFieldKey[],
@@ -50,7 +27,7 @@ export const buildTraceViewColumns = (
...getFieldColumn(field),
defaultVisibility: DEFAULT_VISIBLE_FIELDS.has(field.name),
// The shared column builder pins anything in TIMESTAMP_FIELD_NAMES; these stay movable.
enableMove: true,
enableMove: field.name !== TRACE_ID_COLUMN_ID,
enableRemove: field.name !== TRACE_ID_COLUMN_ID,
canBeHidden: field.name !== TRACE_ID_COLUMN_ID,
}));

View File

@@ -21,11 +21,7 @@ import {
TRACE_VIEW_COLUMN_EXTRA_FIELDS,
TRACE_VIEW_FIELD_KEYS,
} from '../constants';
import {
buildTraceViewColumns,
sortByDefaultOrder,
TRACE_ID_COLUMN_ID,
} from './configs';
import { buildTraceViewColumns, TRACE_ID_COLUMN_ID } from './configs';
const STORAGE_KEY = LOCALSTORAGE.AI_OBSERVABILITY_TRACE_VIEW_COLUMNS;
@@ -59,10 +55,7 @@ export function useTraceViewColumns(): UseTraceViewColumns {
);
const availableFields = useMemo(
() =>
sortByDefaultOrder(
mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
),
() => mergeExtraFields(TRACE_VIEW_COLUMN_EXTRA_FIELDS, fetchedFields),
[fetchedFields],
);

View File

@@ -2,10 +2,11 @@
display: flex;
flex-direction: column;
gap: var(--spacing-8);
:global(.ant-tabs-tabpane) {
padding: var(--spacing-0) var(--spacing-8);
}
--tabs-content-padding: 0;
margin-top: var(--spacing-3);
padding: var(--spacing-0) var(--spacing-8);
--tabs-text-color: var(--l1-foreground);
--tabs-active-text-color: var(--l1-foreground);
}
.tabLabel {

View File

@@ -1,5 +1,5 @@
import { Badge } from '@signozhq/ui/badge';
import { Tabs } from 'antd';
import { Tabs } from '@signozhq/ui/tabs';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import { parseAsStringEnum, useQueryState } from 'nuqs';
@@ -26,7 +26,7 @@ function LLMObservabilityModelPricing(): JSX.Element {
data-testid="llm-observability-model-pricing-page"
>
<Tabs
activeKey={activeTab}
value={activeTab}
onChange={(key): void => {
void setActiveTab(key as typeof activeTab);
}}

View File

@@ -1,7 +1,3 @@
.fieldLabel {
composes: fieldLabel from './shared.module.scss';
}
.drawerSection {
composes: drawerSection from './shared.module.scss';
}
@@ -21,9 +17,6 @@
--dialog-header-padding: var(--spacing-10) var(--spacing-12);
--dialog-footer-padding: var(--spacing-8) var(--spacing-12);
--input-foreground: var(--l1-foreground);
--input-placeholder-color: var(--l3-foreground);
display: flex;
overflow: hidden;

View File

@@ -109,7 +109,7 @@ function ModelCostDrawer({
drawerHeaderProps={{ className: styles.title }}
>
<div className={styles.drawerSection}>
<label htmlFor="billing-model-id" className={styles.fieldLabel}>
<label htmlFor="billing-model-id">
Billing Model ID{' '}
<span className={styles.required} aria-hidden="true">
*
@@ -144,9 +144,7 @@ function ModelCostDrawer({
</div>
<div className={styles.drawerSection}>
<label htmlFor="provider-select" className={styles.fieldLabel}>
Provider
</label>
<label htmlFor="provider-select">Provider</label>
<Controller
name="provider"
control={control}

View File

@@ -1,7 +1,3 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -67,7 +67,9 @@ function ExtraPricingBuckets({
return (
<div className={cx(styles.extraBucketsSection, styles.drawerSection)}>
<div className={styles.extraBucketsSectionHead}>
<span className={styles.fieldLabel}>Extra Pricing Buckets</span>
<Typography.Text as="span" size="small" color="muted">
Extra Pricing Buckets
</Typography.Text>
<Typography.Text as="span" size="small" color="muted">
Optional
</Typography.Text>
@@ -114,9 +116,7 @@ function ExtraPricingBuckets({
{addedBuckets.length > 0 && (
<div className={cx(styles.pricingField, styles.cacheModeField)}>
<label htmlFor="cache-mode" className={styles.fieldLabel}>
Cache mode
</label>
<label htmlFor="cache-mode">Cache mode</label>
<SelectSimple
id="cache-mode"
value={pricing.cacheMode}

View File

@@ -1,7 +1,3 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -37,12 +37,12 @@ function PatternEditor({
return (
<div className={styles.drawerSection}>
<span className={styles.fieldLabel}>
<Typography.Text as="span">
Model name patterns{' '}
<Typography.Text as="span" color="muted">
(prefix match)
</Typography.Text>
</span>
</Typography.Text>
<div className={styles.patternBox}>
<div className={styles.patternChips}>
{patterns.map((pattern) => (

View File

@@ -1,7 +1,3 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -24,7 +24,9 @@ function PricingFields({
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<span className={styles.fieldLabel}>Pricing (per 1M tokens, USD)</span>
<Typography.Text size="base" weight="bold">
Pricing (per 1M tokens, USD)
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-readonly-label">
@@ -36,7 +38,7 @@ function PricingFields({
</div>
<div className={styles.pricingGrid}>
<div className={styles.pricingField}>
<label htmlFor="input-cost" className={styles.fieldLabel}>
<label htmlFor="input-cost">
Input Cost{' '}
<span className={styles.required} aria-hidden="true">
*
@@ -56,7 +58,7 @@ function PricingFields({
/>
</div>
<div className={styles.pricingField}>
<label htmlFor="output-cost" className={styles.fieldLabel}>
<label htmlFor="output-cost">
Output Cost{' '}
<span className={styles.required} aria-hidden="true">
*

View File

@@ -1,7 +1,3 @@
.fieldLabel {
composes: fieldLabel from '../../shared.module.scss';
}
.drawerSection {
composes: drawerSection from '../../shared.module.scss';
}

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
import { Lock } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import styles from './SourceSelector.module.scss';
@@ -41,7 +42,9 @@ function SourceSelector({
return (
<div className={cx(styles.drawerSection, styles.drawerSurface)}>
<div className={styles.drawerSurfaceHead}>
<span className={styles.fieldLabel}>Source</span>
<Typography.Text weight="bold" size="base">
Source
</Typography.Text>
{isReadOnly && (
<span className={styles.managedLabel} data-testid="drawer-managed-label">

View File

@@ -47,14 +47,6 @@
color: var(--accent-cherry);
}
/* Single treatment for every label in the drawer, so field labels and the */
.fieldLabel {
font-size: var(--periscope-font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--spacing-10);
color: var(--l2-foreground);
}
.pricingField {
display: flex;
flex-direction: column;

View File

@@ -167,7 +167,7 @@ describe('UnpricedModelsTab (integration)', () => {
await screen.findByTestId(`unpriced-model-name-${MODEL}`);
// Open the row's dropdown and take the "Create a new pricing model" escape hatch
// Open the row's dropdown and take the "Create pricing for …" escape hatch
// instead of mapping onto an existing billing model.
await user.click(screen.getByTestId(`map-to-select-${MODEL}`));
await user.click(await screen.findByTestId(`map-to-create-${MODEL}`));

View File

@@ -14,24 +14,6 @@
width: 280px;
}
.footer {
padding: var(--spacing-2);
background-color: var(--l2-background);
}
.createItem {
gap: var(--spacing-4);
font-style: normal;
color: var(--accent-primary);
--command-item-cursor: pointer;
--command-item-svg-size: var(--spacing-7);
&[data-selected='true'] {
background-color: var(--callout-primary-background);
color: var(--accent-primary);
}
}
.skeletonList {
display: flex;
flex-direction: column;

View File

@@ -119,18 +119,15 @@ function MapToBillingModelSelect({
options scroll. Escape hatch when no existing billing model fits:
define this model's own pricing rather than mapping onto another. */}
<ComboboxSeparator alwaysRender />
<div className={styles.footer}>
<ComboboxCreateItem
className={styles.createItem}
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create a new pricing model
</ComboboxCreateItem>
</div>
<ComboboxCreateItem
inputValue={modelName}
value={`create-pricing-${modelName}`}
prefix={<Plus size={14} />}
onSelect={handleCreateNew}
testId={`map-to-create-${modelName}`}
>
Create pricing for &quot;{modelName}&quot;
</ComboboxCreateItem>
</ComboboxCommand>
</ComboboxContent>
</Combobox>

View File

@@ -1,18 +1,7 @@
.meter-explorer-container {
display: flex;
flex-direction: row;
.meter-explorer-quick-filters-section {
width: 280px;
border-right: 1px solid var(--l1-border);
&.hidden {
display: none;
}
}
.meter-explorer-content-section {
width: 100%;
// Clearance for the fixed ExplorerOptions bar.
padding-bottom: 80px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
@@ -83,14 +72,6 @@
}
}
}
&.quick-filters-open {
.meter-explorer-content-section {
width: calc(100% - 280px);
}
}
padding-bottom: 80px;
}
.dashboards-and-alerts-popover-container {

View File

@@ -3,9 +3,8 @@ import { useQueryClient } from 'react-query';
import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
@@ -121,29 +120,21 @@ function Explorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div
className={cx('meter-explorer-container', {
'quick-filters-open': showQuickFilters,
})}
<QuickFiltersLayout
className="meter-explorer-container"
showFilters={showQuickFilters}
quickFilterProps={{
className: 'qf-meter-explorer',
source: QuickFiltersSource.METER_EXPLORER,
signal: SignalType.METER_EXPLORER,
showFilterCollapse: true,
showQueryName: false,
handleFilterVisibilityChange: (): void => {
setShowQuickFilters(!showQuickFilters);
},
useFieldApis: quickFilterFieldApis,
}}
>
<div
className={cx('meter-explorer-quick-filters-section', {
hidden: !showQuickFilters,
})}
>
<QuickFilters
className="qf-meter-explorer"
source={QuickFiltersSource.METER_EXPLORER}
signal={SignalType.METER_EXPLORER}
showFilterCollapse
showQueryName={false}
handleFilterVisibilityChange={(): void => {
setShowQuickFilters(!showQuickFilters);
}}
useFieldApis={quickFilterFieldApis}
/>
</div>
<div className="meter-explorer-content-section">
<div className="meter-explorer-explore-content">
<div className="explore-header">
@@ -196,7 +187,7 @@ function Explorer(): JSX.Element {
splitedQueries={splitedQueries}
/>
</div>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -60,9 +60,6 @@
.metrics-table-container {
padding-bottom: 48px;
.ant-table {
margin-left: -16px;
margin-right: -16px;
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;

View File

@@ -1,11 +1,4 @@
.all-errors-page {
display: flex;
height: 100%;
.all-errors-quick-filter-section {
width: 0%;
flex-shrink: 0;
}
.all-errors-right-section {
.right-toolbar-actions-container {
display: flex;
@@ -18,14 +11,4 @@
.ant-tabs {
margin: 0 8px;
}
&.filter-visible {
.all-errors-quick-filter-section {
width: 260px;
}
.all-errors-right-section {
width: calc(100% - 260px);
}
}
}

View File

@@ -5,13 +5,11 @@ import { Filter } from '@signozhq/icons';
import { Button, Tooltip } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import RouteTab from 'components/RouteTab';
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import ResourceAttributesFilterV2 from 'container/ResourceAttributeFilterV2/ResourceAttributesFilterV2';
@@ -59,63 +57,52 @@ function AllErrors(): JSX.Element {
const quickFilterFieldApis = useSignalFieldApis();
return (
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
<section className={cx('all-errors-quick-filter-section')}>
<QuickFilters
className="qf-exceptions"
source={QuickFiltersSource.EXCEPTIONS}
signal={SignalType.EXCEPTIONS}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={quickFilterFieldApis}
/>
</section>
)}
<section
className={cx(
'all-errors-right-section',
showFilters ? 'filter-visible' : '',
)}
>
<TypicalOverlayScrollbar>
<>
<Toolbar
showAutoRefresh={false}
leftActions={
!showFilters ? (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<Filter size="md" />
</Button>
</Tooltip>
) : undefined
}
rightActions={
<div className="right-toolbar-actions-container">
<RightToolbarActions
onStageRunQuery={handleRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</div>
}
<QuickFiltersLayout
className="all-errors-page"
contentClassName="all-errors-right-section"
showFilters={showFilters}
quickFilterProps={{
className: 'qf-exceptions',
source: QuickFiltersSource.EXCEPTIONS,
signal: SignalType.EXCEPTIONS,
handleFilterVisibilityChange,
useFieldApis: quickFilterFieldApis,
}}
>
<Toolbar
showAutoRefresh={false}
leftActions={
!showFilters ? (
<Tooltip title="Show Filters">
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
<Filter size="md" />
</Button>
</Tooltip>
) : undefined
}
rightActions={
<div className="right-toolbar-actions-container">
<RightToolbarActions
onStageRunQuery={handleRunQuery}
isLoadingQueries={isLoadingQueries}
handleCancelQuery={handleCancelQuery}
/>
<ResourceAttributesFilterV2 />
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
showRightSection={false}
<HeaderRightSection
enableAnnouncements={false}
enableShare
enableFeedback
/>
</>
</TypicalOverlayScrollbar>
</section>
</div>
</div>
}
/>
<ResourceAttributesFilterV2 />
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
showRightSection={false}
/>
</QuickFiltersLayout>
);
}

View File

@@ -1,11 +1,4 @@
.api-monitoring-page {
flex: 1;
display: flex;
.ant-tabs {
flex: 1;
}
.ant-tabs-nav {
padding: 0 16px;
margin-bottom: 0px;
@@ -15,22 +8,6 @@
}
}
.ant-tabs-content-holder {
display: flex;
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
display: flex;
flex-direction: column;
}
}
}
.tab-item {
display: flex;
justify-content: center;

View File

@@ -13,9 +13,12 @@ function ApiMonitoringPage(): JSX.Element {
const routes: TabRoutes[] = [Explorer];
return (
<div className="api-monitoring-page">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="api-monitoring-page"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -1,13 +1,4 @@
.infra-monitoring-module-container {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.ant-tabs {
height: 100%;
}
.ant-tabs-nav {
padding: 0 8px;
margin-bottom: 0px;
@@ -17,22 +8,6 @@
}
}
.ant-tabs-content-holder {
display: flex;
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
display: flex;
flex-direction: column;
}
}
}
.tab-item {
display: flex;
justify-content: center;

View File

@@ -13,8 +13,11 @@ export default function InfrastructureMonitoringPage(): JSX.Element {
const routes: TabRoutes[] = [Hosts, Kubernetes];
return (
<div className="infra-monitoring-module-container">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="infra-monitoring-module-container"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -22,6 +22,5 @@
justify-content: center;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-1) var(--spacing-0);
}
}

View File

@@ -1,16 +1,4 @@
.logs-module-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.ant-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.ant-tabs-nav {
padding: 0 16px;
margin-bottom: 0px;
@@ -20,25 +8,6 @@
}
}
.ant-tabs-content-holder {
display: flex;
min-height: 0;
.ant-tabs-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
}
.tab-item {
display: flex;
justify-content: center;

View File

@@ -13,8 +13,11 @@ export default function LogsModulePage(): JSX.Element {
const routes: TabRoutes[] = [logsExplorer, logsPipelines, logSaveView];
return (
<div className="logs-module-container">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="logs-module-container"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -1,13 +1,4 @@
.messaging-queues-module-container {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.ant-tabs {
height: 100%;
}
.ant-tabs-nav {
padding: 0 8px;
margin-bottom: 0px;
@@ -17,22 +8,6 @@
}
}
.ant-tabs-content-holder {
display: flex;
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
display: flex;
flex-direction: column;
}
}
}
.tab-item {
display: flex;
justify-content: center;

View File

@@ -68,8 +68,11 @@ export default function MessagingQueuesMainPage(): JSX.Element {
];
return (
<div className="messaging-queues-module-container">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="messaging-queues-module-container"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -14,14 +14,13 @@ function MeterExplorerPage(): JSX.Element {
const routes: TabRoutes[] = [Meter, Explorer, Views];
return (
<div className="meter-explorer-page">
<RouteTab
routes={routes}
activeKey={pathname}
history={history}
defaultActiveKey={ROUTES.METER}
/>
</div>
<RouteTab
className="meter-explorer-page"
routes={routes}
activeKey={pathname}
history={history}
defaultActiveKey={ROUTES.METER}
/>
);
}

View File

@@ -1,13 +1,4 @@
.metrics-explorer-page {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
.ant-tabs {
height: 100%;
}
.ant-tabs-nav {
padding-left: 16px;
margin-bottom: 0px;
@@ -18,20 +9,7 @@
}
.ant-tabs-content-holder {
display: flex;
padding: 16px;
.ant-tabs-content {
flex: 1;
display: flex;
flex-direction: column;
.ant-tabs-tabpane {
flex: 1;
display: flex;
flex-direction: column;
}
}
}
.tab-item {

View File

@@ -42,9 +42,12 @@ function MetricsExplorerPage(): JSX.Element {
useShareBuilderUrl({ defaultValue: defaultQuery });
return (
<div className="metrics-explorer-page">
<RouteTab routes={routes} activeKey={pathname} history={history} />
</div>
<RouteTab
className="metrics-explorer-page"
routes={routes}
activeKey={pathname}
history={history}
/>
);
}

View File

@@ -65,8 +65,6 @@
}
.trace-explorer-page {
display: flex;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
@@ -75,32 +73,8 @@
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
width: 260px;
height: 100%;
min-height: 100vh;
border-right: 0px;
border: 1px solid var(--l1-border);
background-color: var(--l1-background);
> .ant-card-body {
padding: 0;
width: 258px;
}
}
.trace-explorer {
width: 100%;
background: var(--l1-background);
> .ant-card-body {
padding: 0;
}
border-color: var(--l1-border);
}
.trace-explorer.filters-expanded {
width: calc(100% - 260px);
}
}

View File

@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -261,23 +259,20 @@ function TracesExplorer(): JSX.Element {
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="trace-explorer-page">
<Card className="filter" hidden={!isOpen}>
<QuickFilters
className="qf-traces-explorer"
source={QuickFiltersSource.TRACES_EXPLORER}
signal={SignalType.TRACES}
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
useFieldApis={quickFilterFieldApis}
/>
</Card>
<div
className={cx('trace-explorer', {
'filters-expanded': isOpen,
})}
>
<QuickFiltersLayout
className="trace-explorer-page"
showFilters={isOpen}
quickFilterProps={{
className: 'qf-traces-explorer',
source: QuickFiltersSource.TRACES_EXPLORER,
signal: SignalType.TRACES,
handleFilterVisibilityChange: (): void => {
setOpen(!isOpen);
},
useFieldApis: quickFilterFieldApis,
}}
>
<div className="trace-explorer">
<div className="trace-explorer-header">
<Toolbar
showAutoRefresh
@@ -369,7 +364,7 @@ function TracesExplorer(): JSX.Element {
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
</div>
</QuickFiltersLayout>
</Sentry.ErrorBoundary>
);
}

View File

@@ -25,16 +25,15 @@ function TracesModulePage(): JSX.Element {
};
return (
<div className="traces-module-container">
<RouteTab
routes={routes}
activeKey={
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
}
history={history}
onChangeHandler={handleTabChange}
/>
</div>
<RouteTab
className="traces-module-container"
routes={routes}
activeKey={
pathname.includes(ROUTES.TRACES_FUNNELS) ? ROUTES.TRACES_FUNNELS : pathname
}
history={history}
onChangeHandler={handleTabChange}
/>
);
}

View File

@@ -37,7 +37,7 @@ func (provider *provider) addLLMPricingRuleRoutes(router *mux.Router) error {
ID: "CreateOrUpdateLLMPricingRules",
Tags: []string{"llmpricingrules"},
Summary: "Create or update pricing rules",
Description: "Single write endpoint used by both the user and the Zeus sync job. Rules without isOverride are matched by sourceId and override rows (is_override=true) are skipped. Rules with isOverride are matched by id and inserted when new.",
Description: "Single write endpoint used by both the user and the Zeus sync job. Per-rule match is by id, then sourceId, then insert. Override rows (is_override=true) are fully preserved when the request does not provide isOverride; only synced_at is stamped.",
Request: new(llmpricingruletypes.UpdatableLLMPricingRules),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,

View File

@@ -60,29 +60,38 @@ func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID)
return unmapped, nil
}
// CreateOrUpdate saves a batch of pricing rules. isOverride decides how a rule
// is matched, see UpdatableLLMPricingRule. New rules are inserted on either path.
// CreateOrUpdate applies a batch of pricing rule changes:
// - ID set → match by id, overwrite fields.
// - SourceID set → match by source_id; if found overwrite, else insert.
// - neither set → insert a new user-created row (is_override = true).
//
// When UpdatableLLMPricingRule.IsOverride is nil AND the matched row has
// is_override = true, the row is fully preserved — only synced_at is stamped.
func (module *module) CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) error {
now := time.Now()
var byID, bySourceID []*llmpricingruletypes.LLMPricingRule
for _, u := range rules {
upsert := func(ctx context.Context, u *llmpricingruletypes.UpdatableLLMPricingRule) error {
if u == nil {
return errors.Newf(errors.TypeInvalidInput, llmpricingruletypes.ErrCodePricingRuleInvalidInput, "rule entry is null")
}
rule := llmpricingruletypes.NewLLMPricingRuleFromUpdatable(u, orgID, userEmail, now)
if u.IsOverride == nil {
bySourceID = append(bySourceID, rule)
} else {
byID = append(byID, rule)
existing, err := module.findExisting(ctx, orgID, u)
if err != nil && errors.Ast(err, errors.TypeNotFound) {
return module.store.Create(ctx, llmpricingruletypes.NewLLMPricingRuleFromUpdatable(u, orgID, userEmail, now))
}
if err != nil {
return err
}
existing.Update(u, userEmail, now)
return module.store.Update(ctx, existing)
}
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.UpsertByID(ctx, byID); err != nil {
return err
for _, u := range rules {
if err := upsert(ctx, u); err != nil {
return err
}
}
return module.store.UpsertBySourceID(ctx, bySourceID)
return nil
})
if err != nil {
return err
@@ -163,6 +172,20 @@ func (module *module) listAllRules(ctx context.Context, orgID valuer.UUID) ([]*l
return all, nil
}
// findExisting returns the row matching the updatable's ID or SourceID.
// Returns a TypeNotFound error when neither matches; the caller treats that
// as "insert new".
func (module *module) findExisting(ctx context.Context, orgID valuer.UUID, u *llmpricingruletypes.UpdatableLLMPricingRule) (*llmpricingruletypes.LLMPricingRule, error) {
switch {
case u.ID != nil:
return module.store.Get(ctx, orgID, *u.ID)
case u.SourceID != nil:
return module.store.GetBySourceID(ctx, orgID, *u.SourceID)
default:
return nil, errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "rule has neither id nor sourceId")
}
}
// discoverModels runs a QBv5 traces aggregation grouped by gen_ai.request.model
// over the lookback window and returns each distinct model with its span count.
func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {

View File

@@ -7,13 +7,8 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
// Columns an existing row gets when a rule matches it. id, org_id, source_id,
// created_at and created_by are never changed.
var upsertColumns = []string{"model", "provider", "model_pattern", "unit", "pricing", "is_override", "enabled", "synced_at", "updated_at", "updated_by"}
type store struct {
sqlstore sqlstore.SQLStore
}
@@ -69,60 +64,57 @@ func (store *store) Get(ctx context.Context, orgID, id valuer.UUID) (*llmpricing
return rule, nil
}
// UpsertByID replaces the row with the same id, or inserts when there is
// none. Rows of other orgs are left alone and reported as not found.
func (store *store) UpsertByID(ctx context.Context, rules []*llmpricingruletypes.LLMPricingRule) error {
if len(rules) == 0 {
return nil
func (store *store) GetBySourceID(ctx context.Context, orgID, sourceID valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error) {
rule := new(llmpricingruletypes.LLMPricingRule)
err := store.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(rule).
Where("org_id = ?", orgID).
Where("source_id = ?", sourceID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule with source_id %s not found in the org", sourceID)
}
// bun can overwrite the rules slice with the rows it gets back, so count first.
expected := len(rules)
query := store.sqlstore.
return rule, nil
}
func (store *store) Create(ctx context.Context, rule *llmpricingruletypes.LLMPricingRule) error {
_, err := store.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&rules).
On("CONFLICT (id) DO UPDATE").
Where("llm_pricing_rule.org_id = EXCLUDED.org_id")
for _, col := range upsertColumns {
query = query.Set("? = EXCLUDED.?", bun.Ident(col), bun.Ident(col))
}
res, err := query.Exec(ctx)
Model(rule).
Exec(ctx)
if err != nil {
return err
return store.sqlstore.WrapAlreadyExistsErrf(err, llmpricingruletypes.ErrCodePricingRuleAlreadyExists, "pricing rule with model %s already exists", rule.Model)
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
if int(affected) != expected {
return errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "one or more pricing rules not found in the org")
}
return nil
}
// UpsertBySourceID replaces the row with the same source_id, or inserts when
// there is none. Rows the user has overridden are skipped.
func (store *store) UpsertBySourceID(ctx context.Context, rules []*llmpricingruletypes.LLMPricingRule) error {
if len(rules) == 0 {
return nil
}
query := store.sqlstore.
func (store *store) Update(ctx context.Context, rule *llmpricingruletypes.LLMPricingRule) error {
res, err := store.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&rules).
On("CONFLICT (org_id, source_id) WHERE source_id IS NOT NULL DO UPDATE").
Where("NOT llm_pricing_rule.is_override")
for _, col := range upsertColumns {
query = query.Set("? = EXCLUDED.?", bun.Ident(col), bun.Ident(col))
NewUpdate().
Model(rule).
Where("org_id = ?", rule.OrgID).
Where("id = ?", rule.ID).
ExcludeColumn("id", "org_id", "source_id", "created_at", "created_by").
Exec(ctx)
if err != nil {
return err
}
_, err := query.Exec(ctx)
return err
rowsAffected, err := res.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "pricing rule %s not found in the org", rule.ID)
}
return nil
}
func (store *store) Delete(ctx context.Context, orgID, id valuer.UUID) error {

View File

@@ -248,18 +248,8 @@ func (module *setter) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return err
}
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.tokenizer.DeleteTokensByUserID(ctx, user.ID); err != nil {
return err
}
if err := module.tokenizer.DeleteIdentity(ctx, user.ID); err != nil {
return err
}
// for now we are only soft deleting users
return module.store.SoftDeleteUser(ctx, orgID.String(), user.ID.StringValue())
}); err != nil {
// for now we are only soft deleting users
if err := module.store.SoftDeleteUser(ctx, orgID.String(), user.ID.StringValue()); err != nil {
return err
}

View File

@@ -218,12 +218,19 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
}
func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string) error {
tx := store.sqlstore.BunDBCtx(ctx)
tx, err := store.sqlstore.BunDB().BeginTx(ctx, nil)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to start transaction")
}
defer func() {
_ = tx.Rollback()
}()
// get the password id
var password types.FactorPassword
err := tx.NewSelect().
err = tx.NewSelect().
Model(&password).
Where("user_id = ?", id).
Scan(ctx)
@@ -289,6 +296,11 @@ func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string)
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to delete user")
}
err = tx.Commit()
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to commit transaction")
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More