mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-18 01:10:41 +01:00
Compare commits
5 Commits
chore/scaf
...
issue-2946
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83402ebea8 | ||
|
|
1285e0a5f2 | ||
|
|
f82a9ee528 | ||
|
|
9f03fea0f3 | ||
|
|
d1a382945c |
@@ -11012,9 +11012,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:list
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:list
|
||||
summary: List accounts
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11069,9 +11069,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:create
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:create
|
||||
summary: Create account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11114,9 +11114,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:delete
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:delete
|
||||
summary: Disconnect account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11182,9 +11182,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:read
|
||||
summary: Get account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11231,9 +11231,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:update
|
||||
summary: Update account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11289,9 +11289,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration-service:list
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration-service:list
|
||||
summary: List account services metadata
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11364,9 +11364,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration-service:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration-service:read
|
||||
summary: Get service for account
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11418,9 +11418,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration-service:update
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration-service:update
|
||||
summary: Update service
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11528,9 +11528,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- cloud-integration:create
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- cloud-integration:create
|
||||
summary: Get connection credentials
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11580,10 +11580,8 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
summary: List services metadata
|
||||
tags:
|
||||
- cloudintegration
|
||||
@@ -11638,10 +11636,8 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
summary: Get service
|
||||
tags:
|
||||
- cloudintegration
|
||||
|
||||
@@ -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.
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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. |
|
||||
@@ -1,4 +0,0 @@
|
||||
.__camel__ {
|
||||
display: flex;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
@@ -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__;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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. -->
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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__;
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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__
|
||||
@@ -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__;
|
||||
@@ -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",
|
||||
|
||||
@@ -5,13 +5,15 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
citypes "github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/credentials", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetConnectionCredentials),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.GetConnectionCredentials, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetConnectionCredentials",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -24,14 +26,20 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbCreate, // get or create the credentials, so we use create verb here
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.CreateAccount),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.CreateAccount, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateAccount",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -44,14 +52,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.ListAccounts),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.ListAccounts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListAccounts",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -64,14 +79,20 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetAccount),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.GetAccount, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetAccount",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -84,14 +105,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.UpdateAccount),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.UpdateAccount, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateAccount",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -104,14 +132,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.DisconnectAccount),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.DisconnectAccount, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DisconnectAccount",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -124,14 +159,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegration.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegration,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.ListServicesMetadata),
|
||||
provider.authzMiddleware.OpenAccess(provider.cloudIntegrationHandler.ListServicesMetadata),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListServicesMetadata",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -144,14 +186,14 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes(nil),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.ListAccountServicesMetadata),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.ListAccountServicesMetadata, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListAccountServicesMetadata",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -164,14 +206,20 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegrationService.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegrationService,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/services/{service_id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetService),
|
||||
provider.authzMiddleware.OpenAccess(provider.cloudIntegrationHandler.GetService),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetService",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -184,14 +232,14 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes(nil),
|
||||
},
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.UpdateService),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.UpdateService, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateService",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -204,14 +252,21 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegrationService.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegrationService,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.PathParam("service_id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/{id}/services/{service_id}", handler.New(
|
||||
provider.authzMiddleware.AdminAccess(provider.cloudIntegrationHandler.GetAccountService),
|
||||
provider.authzMiddleware.CheckResources(provider.cloudIntegrationHandler.GetAccountService, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetAccountService",
|
||||
Tags: []string{"cloudintegration"},
|
||||
@@ -224,8 +279,15 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceCloudIntegrationService.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceCloudIntegrationService,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: coretypes.PathParam("service_id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -252,6 +314,7 @@ func (provider *provider) addCloudIntegrationRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: figure out authz permission model for this endppoint without breaking existing deployed agents.
|
||||
if err := router.Handle("/api/v1/cloud_integrations/{cloud_provider}/accounts/check_in", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.cloudIntegrationHandler.AgentCheckIn),
|
||||
handler.OpenAPIDef{
|
||||
|
||||
@@ -25,8 +25,9 @@ const (
|
||||
// ResolveLogicalFields picks which logical fields a filter term builds conditions
|
||||
// for. With 0 or 1 field it returns the input unchanged and no warning. When a
|
||||
// name is ambiguous (several logical fields — a family is one field and never
|
||||
// ambiguous with itself) it returns a warning; a resource+attribute mix defaults
|
||||
// to the resource fields (the common intent), noted in the warning.
|
||||
// ambiguous with itself) it returns a warning; a resource + other-context mix
|
||||
// (attribute, body, scope, …) defaults to the resource fields (the common
|
||||
// intent), noted in the warning.
|
||||
func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields []*telemetrytypes.LogicalField) ([]*telemetrytypes.LogicalField, string) {
|
||||
if len(logicalFields) <= 1 {
|
||||
return logicalFields, ""
|
||||
@@ -39,18 +40,17 @@ func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields
|
||||
logicalFields,
|
||||
)
|
||||
|
||||
hasResource, hasAttribute := false, false
|
||||
hasResource, hasOther := false, false
|
||||
for _, item := range logicalFields {
|
||||
switch item.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
if item.FieldContext == telemetrytypes.FieldContextResource {
|
||||
hasResource = true
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
hasAttribute = true
|
||||
} else {
|
||||
hasOther = true
|
||||
}
|
||||
}
|
||||
|
||||
// when there is both resource and attribute context, default to resource only
|
||||
if hasResource && hasAttribute {
|
||||
// with resource and any other context, default to resource only
|
||||
if hasResource && hasOther {
|
||||
filtered := make([]*telemetrytypes.LogicalField, 0, len(logicalFields))
|
||||
for _, item := range logicalFields {
|
||||
if item.FieldContext == telemetrytypes.FieldContextResource {
|
||||
@@ -58,8 +58,8 @@ func ResolveLogicalFields(field *telemetrytypes.TelemetryFieldKey, logicalFields
|
||||
}
|
||||
}
|
||||
logicalFields = filtered
|
||||
warning += " " + "Using `resource` context by default. To query attributes explicitly, " +
|
||||
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s')", field.Name)
|
||||
warning += " " + "Using `resource` context by default. To query another context explicitly, " +
|
||||
fmt.Sprintf("use the fully qualified name (e.g., 'attribute.%s' or 'body.%s')", field.Name, field.Name)
|
||||
}
|
||||
|
||||
return logicalFields, warning
|
||||
|
||||
@@ -175,6 +175,42 @@ func TestResolveLogicalFieldsKeepsFamilyThroughAmbiguity(t *testing.T) {
|
||||
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(resolved[0]))
|
||||
}
|
||||
|
||||
// Resource wins over every other context, not just attribute: a bare key that
|
||||
// also lives in body or scope must collapse to resource alone, so the surviving
|
||||
// candidate does not AND against the resource fingerprint CTE.
|
||||
func TestResolveLogicalFieldsResourceWinsOverOtherContexts(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
other telemetrytypes.FieldContext
|
||||
}{
|
||||
{name: "ResourceOverBody", other: telemetrytypes.FieldContextBody},
|
||||
{name: "ResourceOverScope", other: telemetrytypes.FieldContextScope},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
requested := &telemetrytypes.TelemetryFieldKey{Name: "service.name"}
|
||||
fields := []*telemetrytypes.LogicalField{
|
||||
telemetrytypes.SingleLogicalField("service.name", &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}),
|
||||
telemetrytypes.SingleLogicalField("service.name", &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
FieldContext: testCase.other,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}),
|
||||
}
|
||||
|
||||
resolved, warning := ResolveLogicalFields(requested, fields)
|
||||
assert.NotEmpty(t, warning)
|
||||
require.Len(t, resolved, 1)
|
||||
assert.Equal(t, telemetrytypes.FieldContextResource, resolved[0].FieldContext)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Members of a family with different data types never merge: the identity
|
||||
// (signal, context, data type) separates them into distinct logical fields.
|
||||
func TestMatchingLogicalFieldsNeverMergesAcrossDataTypes(t *testing.T) {
|
||||
|
||||
@@ -254,6 +254,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddIngestionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSubscriptionTuplesFactory(sqlstore),
|
||||
sqlmigration.NewNormalizeQuickFilterFieldsFactory(sqlstore),
|
||||
sqlmigration.NewAddCloudIntegrationTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
175
pkg/sqlmigration/127_add_cloud_integration_tuples.go
Normal file
175
pkg/sqlmigration/127_add_cloud_integration_tuples.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addCloudIntegrationTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddCloudIntegrationTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_cloud_integration_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addCloudIntegrationTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addCloudIntegrationTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addCloudIntegrationTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// cloud-integration and cloud-integration-service moved from legacy role
|
||||
// gates to CheckResources. Existing organizations need the same tuples that
|
||||
// new organizations receive from the managed-role registry at bootstrap.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "delete"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration", "list"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration-service", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration-service", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "cloud-integration-service", "list"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration", "read"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration", "update"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration", "list"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration-service", "read"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration-service", "update"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "cloud-integration-service", "list"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "cloud-integration", "read"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "cloud-integration", "list"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "cloud-integration-service", "read"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "cloud-integration-service", "list"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managedRoleGroups[roleName] = string(data)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for roleName, data := range managedRoleGroups {
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", data).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addCloudIntegrationTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logsstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A key present in both resource and body contexts must filter on resource only.
|
||||
// The resource condition builds the fingerprint CTE, so a surviving body condition
|
||||
// would AND against it and match almost nothing (engineering-pod#6086).
|
||||
func TestStatementBuilderResourceBodyConflict(t *testing.T) {
|
||||
store := telemetrytypestest.NewMockMetadataStore()
|
||||
store.SetStaticFields(logstelemetryschema.IntrinsicFields)
|
||||
store.SetKey(&telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
})
|
||||
bodyKey := &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextBody,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
require.NoError(t, bodyKey.SetJSONAccessPlan(telemetrytypes.JSONColumnMetadata{
|
||||
BaseColumn: logstelemetryschema.LogsV2BodyV2Column,
|
||||
PromotedColumn: logstelemetryschema.LogsV2BodyPromotedColumn,
|
||||
}, map[string][]telemetrytypes.FieldDataType{"service.name": {telemetrytypes.FieldDataTypeString}}))
|
||||
store.SetKey(bodyKey)
|
||||
|
||||
fl := flaggertest.WithUseJSONBody(t, true)
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
store,
|
||||
storage,
|
||||
aggExprRewriter,
|
||||
logstelemetryschema.DefaultFullTextColumn,
|
||||
fl,
|
||||
nil,
|
||||
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
|
||||
)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
expected qbtypes.Statement
|
||||
}{
|
||||
{
|
||||
name: "AmbiguousKeyFiltersResourceOnly",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'webapp'"},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body_v2 as body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"webapp", "%service.name%", "%service.name\":\"webapp%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
Warnings: []string{
|
||||
"Key `service.name` is ambiguous, found 2 different combinations of field context / data type: [name=service.name,context=resource,datatype=string name=service.name,context=body,datatype=string]. Using `resource` context by default. To query another context explicitly, use the fully qualified name (e.g., 'attribute.service.name' or 'body.service.name')",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, testCase.requestType, testCase.query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testCase.expected.Query, q.Query)
|
||||
require.Equal(t, testCase.expected.Args, q.Args)
|
||||
require.Equal(t, testCase.expected.Warnings, q.Warnings)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1014,7 +1014,7 @@ func rejectHTTPBasicAuthBeyondPassword(channelName string, httpConfig *commoncfg
|
||||
|
||||
basicAuth := httpConfig.BasicAuth
|
||||
if *basicAuth != (commoncfg.BasicAuth{Username: basicAuth.Username, Password: basicAuth.Password}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth, which is not supported", channelName)
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.basic_auth with fields other than username and password, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1026,8 +1026,8 @@ func rejectHTTPAuthorizationBeyondBearer(channelName string, httpConfig *commonc
|
||||
}
|
||||
|
||||
authorization := httpConfig.Authorization
|
||||
if *authorization != (commoncfg.Authorization{Type: bearerAuthorizationType, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization, which is not supported", channelName)
|
||||
if !strings.EqualFold(authorization.Type, bearerAuthorizationType) || *authorization != (commoncfg.Authorization{Type: authorization.Type, Credentials: authorization.Credentials}) {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets http_config.authorization with fields other than a bearer token, which is not supported", channelName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -542,3 +542,42 @@ func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The HTTP auth scheme is case-insensitive (RFC 7235) and Alertmanager sends
|
||||
// the stored spelling verbatim, so a hand-written receiver may carry any casing.
|
||||
func TestChannelToPostableChannelReadsWebhookBearerSchemeCaseInsensitively(t *testing.T) {
|
||||
sendResolved := config.DefaultWebhookConfig.VSendResolved
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
storedChannelData string
|
||||
expectedWebhookSpec *ChannelWebhookConfig
|
||||
}{
|
||||
{
|
||||
name: "CanonicalBearer",
|
||||
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://a","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://a", BearerToken: "tok"},
|
||||
},
|
||||
{
|
||||
name: "LowercaseBearer",
|
||||
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://b","http_config":{"authorization":{"type":"bearer","credentials":"lower"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://b", BearerToken: "lower"},
|
||||
},
|
||||
{
|
||||
name: "UppercaseBearer",
|
||||
storedChannelData: `{"name":"hook","webhook_configs":[{"send_resolved":true,"url":"https://c","http_config":{"authorization":{"type":"BEARER","credentials":"upper"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
expectedWebhookSpec: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://c", BearerToken: "upper"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channel := Channel{DisplayName: "hook", Data: testCase.storedChannelData}
|
||||
|
||||
postable, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ChannelKindWebhook, postable.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedWebhookSpec, postable.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,17 +35,15 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbAttach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
{Verb: VerbDetach, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindAuthDomain}, WildCardSelectorString)},
|
||||
// cloud-integration — admin only
|
||||
// cloud-integration — admin can fully manage accounts
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
// cloud-integration-service — admin only
|
||||
// cloud-integration-service — admin can read and update account services
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbDelete, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbCreate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
// integration — viewer/editor/admin (install/uninstall via ViewAccess)
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindIntegration}, WildCardSelectorString)},
|
||||
@@ -216,6 +214,14 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
},
|
||||
SigNozEditorRoleName: {
|
||||
// cloud-integration — editor can read and update existing accounts
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
// cloud-integration-service — editor can read and update account services
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
// dashboard — full CRUD
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
@@ -308,6 +314,12 @@ var ManagedRoleToTransactions = map[string][]Transaction{
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
|
||||
},
|
||||
SigNozViewerRoleName: {
|
||||
// cloud-integration — viewer can read accounts
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegration}, WildCardSelectorString)},
|
||||
// cloud-integration-service — viewer can read account services
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindCloudIntegrationService}, WildCardSelectorString)},
|
||||
// dashboard — read only
|
||||
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindDashboard}, WildCardSelectorString)},
|
||||
|
||||
@@ -52,8 +52,8 @@ var (
|
||||
ResourceMetaResourceApdexSetting = NewResourceMetaResource(KindApdexSetting)
|
||||
ResourceMetaResourceAuthDomain = NewResourceMetaResource(KindAuthDomain)
|
||||
ResourceMetaResourceSession = NewResourceMetaResource(KindSession)
|
||||
ResourceMetaResourceCloudIntegration = NewResourceMetaResource(KindCloudIntegration)
|
||||
ResourceMetaResourceCloudIntegrationService = NewResourceMetaResource(KindCloudIntegrationService)
|
||||
ResourceMetaResourceCloudIntegration = NewResourceMetaResource(KindCloudIntegration, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
|
||||
ResourceMetaResourceCloudIntegrationService = NewResourceMetaResource(KindCloudIntegrationService, VerbList, VerbRead, VerbUpdate)
|
||||
ResourceMetaResourceIntegration = NewResourceMetaResource(KindIntegration)
|
||||
ResourceMetaResourceDashboard = NewResourceMetaResource(KindDashboard, VerbCreate, VerbList, VerbRead, VerbUpdate, VerbDelete)
|
||||
ResourceMetaResourcePublicDashboard = NewResourceMetaResource(KindPublicDashboard)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
build_raw_query,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
|
||||
|
||||
def test_resource_body_conflict(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
export_json_types: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
now = datetime.now(tz=UTC)
|
||||
start_ms = int((now - timedelta(seconds=10)).timestamp() * 1000)
|
||||
end_ms = int(now.timestamp() * 1000)
|
||||
|
||||
# python's body carries service.name, making the bare key ambiguous across
|
||||
# resource and body; java's body omits it, so ANDing body in would drop it.
|
||||
logs_list = [
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=2),
|
||||
resources={"service.name": "java"},
|
||||
body_v2=json.dumps({"msg": "hello"}),
|
||||
body_promoted="",
|
||||
),
|
||||
Logs(
|
||||
timestamp=now - timedelta(seconds=1),
|
||||
resources={"service.name": "python"},
|
||||
body_v2=json.dumps({"service.name": "python"}),
|
||||
body_promoted="",
|
||||
),
|
||||
]
|
||||
export_json_types(logs_list)
|
||||
insert_logs(logs_list)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
cases = [
|
||||
{
|
||||
"name": "bare_key_resolves_to_resource",
|
||||
"filter": "service.name = 'java'",
|
||||
"expected_service_names": ["java"],
|
||||
"expect_resource_warning": True,
|
||||
},
|
||||
{
|
||||
"name": "qualified_body_key_targets_body",
|
||||
"filter": "body.service.name = 'python'",
|
||||
"expected_service_names": ["python"],
|
||||
"expect_resource_warning": False,
|
||||
},
|
||||
]
|
||||
|
||||
for case in cases:
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
request_type="raw",
|
||||
queries=[
|
||||
build_raw_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
filter_expression=case["filter"],
|
||||
limit=100,
|
||||
step_interval=60,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, f"{case['name']}: {response.text}"
|
||||
rows = get_rows(response)
|
||||
assert [row["data"]["resources_string"].get("service.name") for row in rows] == case["expected_service_names"], f"{case['name']}: {response.json()}"
|
||||
|
||||
warning = response.json()["data"].get("warning")
|
||||
if case["expect_resource_warning"]:
|
||||
assert warning is not None and "Using `resource` context by default" in warning["warnings"][0]["message"], f"{case['name']}: {warning}"
|
||||
else:
|
||||
assert warning is None, f"{case['name']}: {warning}"
|
||||
@@ -64,8 +64,8 @@ def test_resource_default_warning(
|
||||
"Key `service.name` is ambiguous, found 2 different combinations of "
|
||||
"field context / data type: [name=service.name,context=resource,datatype=string "
|
||||
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
|
||||
"by default. To query attributes explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name')"
|
||||
"by default. To query another context explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name' or 'body.service.name')"
|
||||
)
|
||||
assert warning["warnings"] == [
|
||||
{"message": expected_service_name_warning},
|
||||
@@ -237,8 +237,8 @@ def test_deduped_warnings_for_single_query(
|
||||
"Key `service.name` is ambiguous, found 2 different combinations of "
|
||||
"field context / data type: [name=service.name,context=resource,datatype=string "
|
||||
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
|
||||
"by default. To query attributes explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name')"
|
||||
"by default. To query another context explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name' or 'body.service.name')"
|
||||
)
|
||||
expected_status_code_warning = "Key `http.status_code` is ambiguous, found 2 different combinations of field context / data type: [name=http.status_code,context=attribute,datatype=number name=http.status_code,context=attribute,datatype=string]."
|
||||
assert warning["warnings"] == [
|
||||
@@ -328,8 +328,8 @@ def test_deduped_warnings_for_multiple_queries(
|
||||
"Key `service.name` is ambiguous, found 2 different combinations of "
|
||||
"field context / data type: [name=service.name,context=resource,datatype=string "
|
||||
"name=service.name,context=attribute,datatype=string]. Using `resource` context "
|
||||
"by default. To query attributes explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name')"
|
||||
"by default. To query another context explicitly, use the fully qualified name "
|
||||
"(e.g., 'attribute.service.name' or 'body.service.name')"
|
||||
)
|
||||
expected_status_code_warning = "Key `http.status_code` is ambiguous, found 2 different combinations of field context / data type: [name=http.status_code,context=attribute,datatype=number name=http.status_code,context=attribute,datatype=string]."
|
||||
assert warning["warnings"] == [
|
||||
|
||||
Reference in New Issue
Block a user