mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-05 00:20:25 +01:00
Compare commits
9 Commits
chore/rule
...
billing-gr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfce3ca329 | ||
|
|
5f6ac9d97b | ||
|
|
83bb47ae5f | ||
|
|
eb29421a4d | ||
|
|
2b8ab1d0d2 | ||
|
|
a77ed9762f | ||
|
|
6a79cbe6fe | ||
|
|
840914ae58 | ||
|
|
7fb222b6ee |
@@ -291,8 +291,6 @@
|
||||
// Prevents the usage of specific antd components in favor of our lib
|
||||
"signoz/no-signozhq-ui-barrel": "error",
|
||||
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
|
||||
"signoz/no-css-module-bracket-access": "warn",
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -2,33 +2,9 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/no-deep-nesting.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/no-id-selectors.js'),
|
||||
path.join(
|
||||
__dirname,
|
||||
'stylelint-rules/css-modules/no-bare-element-selectors.js',
|
||||
),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/prefer-css-variables.js'),
|
||||
path.join(__dirname, 'stylelint-rules/css-modules/class-name-pattern.js'),
|
||||
],
|
||||
plugins: [path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js')],
|
||||
customSyntax: 'postcss-scss',
|
||||
rules: {
|
||||
// Applies to all SCSS files
|
||||
'local/no-unsupported-asset-url': true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// CSS module-specific rules
|
||||
files: ['**/*.module.scss'],
|
||||
rules: {
|
||||
'local/no-deep-nesting': [true, { severity: 'warning' }],
|
||||
'local/no-id-selectors': true,
|
||||
'local/no-bare-element-selectors': true,
|
||||
'local/prefer-css-variables': [true, { severity: 'warning' }],
|
||||
'local/class-name-pattern': [true, { severity: 'warning' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -23,8 +23,6 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Always add data-testid or testId (if supported) to critical/behavioral components like inputs, buttons, etc...
|
||||
- When creating test, these IDs should be used instead of finding by role.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
# CSS Modules Guide for AI Agents
|
||||
|
||||
## Config (vite.config.ts)
|
||||
|
||||
```ts
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** `camelCaseOnly` exports ONLY camelCase keys. Original kebab-case NOT accessible.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| CSS Class | JS Access | Works? |
|
||||
|-----------|-----------|--------|
|
||||
| `.alertHistory` | `styles.alertHistory` | Yes |
|
||||
| `.alert-history` | `styles.alertHistory` | Yes |
|
||||
| `.alert-history` | `styles['alert-history']` | NO - undefined |
|
||||
|
||||
## Bad Patterns
|
||||
|
||||
### Class Naming
|
||||
|
||||
```scss
|
||||
// BAD: Bracket access won't work
|
||||
.my-class { }
|
||||
// Then in JS: styles['my-class'] -> undefined
|
||||
|
||||
// BAD: Collision - both become same key
|
||||
.alertHistory { }
|
||||
.alert-history { } // -> styles.alertHistory (conflicts)
|
||||
|
||||
// BAD: Underscore inconsistency
|
||||
.my_class { } // -> styles.myClass (confusing)
|
||||
|
||||
// GOOD: Direct camelCase
|
||||
.alertHistory { }
|
||||
.statsCard { }
|
||||
```
|
||||
|
||||
### Nesting
|
||||
|
||||
```scss
|
||||
// BAD: Deep nesting - specificity wars, hard to override
|
||||
.container {
|
||||
.wrapper {
|
||||
.inner {
|
||||
.content { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BAD: Nesting creates separate classes you might not expect
|
||||
.button {
|
||||
.icon { } // -> styles.icon (separate class, not scoped under .button)
|
||||
}
|
||||
|
||||
// GOOD: Flat structure
|
||||
.container { }
|
||||
.containerWrapper { }
|
||||
.containerContent { }
|
||||
|
||||
// GOOD: Nesting only for pseudo/states
|
||||
.button {
|
||||
&:hover { }
|
||||
&:disabled { }
|
||||
&::before { }
|
||||
}
|
||||
```
|
||||
|
||||
### Global Escapes
|
||||
|
||||
```scss
|
||||
// BAD: Overusing global
|
||||
:global {
|
||||
.everything { }
|
||||
.in-here { }
|
||||
.is-global { }
|
||||
}
|
||||
|
||||
// BAD: Global without necessity
|
||||
:global(.myComponent) { } // defeats purpose of modules
|
||||
|
||||
// GOOD: Targeted global for third-party overrides
|
||||
.container {
|
||||
:global(.ant-modal-content) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Selectors
|
||||
|
||||
```scss
|
||||
// BAD: ID selectors - not reusable
|
||||
#myComponent { }
|
||||
|
||||
// BAD: Element selectors without scope
|
||||
div { } // affects ALL divs in component
|
||||
|
||||
// BAD: Complex selectors
|
||||
.container > div + span ~ p { }
|
||||
|
||||
// GOOD: Class-only selectors
|
||||
.container { }
|
||||
.title { }
|
||||
```
|
||||
|
||||
### Variables & Values
|
||||
|
||||
```scss
|
||||
// BAD: Hardcoded colors
|
||||
.button {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
// BAD: Magic numbers
|
||||
.container {
|
||||
padding: 17px;
|
||||
margin-left: 43px;
|
||||
}
|
||||
|
||||
// GOOD: Semantic tokens (theme-aware)
|
||||
.button {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
// GOOD: Spacing system
|
||||
.container {
|
||||
padding: var(--spacing-4);
|
||||
margin-left: var(--spacing-5);
|
||||
}
|
||||
```
|
||||
|
||||
## Design Tokens (@signozhq/design-tokens)
|
||||
|
||||
Prefer semantic tokens over hardcoded values.
|
||||
|
||||
You can read the ./node_modules/@signozhq/design-tokens/dist/style.css to find complete list of available tokens.
|
||||
|
||||
### Spacing
|
||||
|
||||
```scss
|
||||
// Spacing scale (index -> px):
|
||||
// --spacing-0=0 --spacing-1=2 --spacing-2=4 --spacing-3=6 --spacing-4=8
|
||||
// --spacing-5=10 --spacing-6=12 --spacing-7=14 --spacing-8=16 --spacing-10=20
|
||||
// --spacing-12=24 --spacing-16=32 --spacing-20=40 --spacing-24=48 --spacing-32=64
|
||||
// --spacing-40=80 --spacing-48=96 --spacing-56=112 --spacing-64=128
|
||||
// (index != px; --spacing-2 is 4px, not 2px)
|
||||
.container {
|
||||
padding: var(--spacing-4); // 8px
|
||||
gap: var(--spacing-6); // 12px
|
||||
margin-bottom: var(--spacing-8); // 16px
|
||||
}
|
||||
|
||||
// Also available: --padding-* and --margin-* (rem-based)
|
||||
// --padding-1 = 0.25rem, --padding-4 = 1rem, etc.
|
||||
```
|
||||
|
||||
### Typography
|
||||
|
||||
```scss
|
||||
// Font sizes (preferred)
|
||||
.title {
|
||||
font-size: var(--periscope-font-size-large); // 18px
|
||||
font-size: var(--periscope-font-size-medium); // 16px
|
||||
font-size: var(--periscope-font-size-base); // 13px
|
||||
font-size: var(--periscope-font-size-small); // 11px
|
||||
}
|
||||
|
||||
// Alternative scale (rem-based)
|
||||
.heading {
|
||||
font-size: var(--font-size-xl); // 1.25rem
|
||||
font-size: var(--font-size-lg); // 1.125rem
|
||||
font-size: var(--font-size-base); // 1rem
|
||||
font-size: var(--font-size-sm); // 0.875rem
|
||||
}
|
||||
|
||||
// Font weights
|
||||
.bold {
|
||||
font-weight: var(--font-weight-semibold); // 600
|
||||
font-weight: var(--font-weight-medium); // 500
|
||||
font-weight: var(--font-weight-normal); // 400
|
||||
}
|
||||
|
||||
// Line heights
|
||||
.text {
|
||||
line-height: var(--line-height-20); // 20px
|
||||
line-height: var(--line-height-24); // 24px
|
||||
}
|
||||
```
|
||||
|
||||
### Colors (Prefer Semantic Tokens)
|
||||
|
||||
Use L1/L2/L3 semantic tokens - they handle light/dark theme automatically.
|
||||
|
||||
```scss
|
||||
// BAD: Primitive tokens (fixed value across themes, won't swap on theme change)
|
||||
.card {
|
||||
background: var(--bg-ink-400);
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
// GOOD: L1/L2/L3 tokens (theme-aware - swap automatically light/dark)
|
||||
.card {
|
||||
background: var(--l1-background); // base layer
|
||||
color: var(--l1-foreground); // primary text
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--l2-background); // elevated surface
|
||||
color: var(--l2-foreground); // secondary text
|
||||
border-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.nested {
|
||||
background: var(--l3-background); // nested/inset
|
||||
color: var(--l3-foreground); // tertiary text
|
||||
}
|
||||
|
||||
// Hover states
|
||||
.card:hover {
|
||||
background: var(--l1-background-hover);
|
||||
color: var(--l1-foreground-hover);
|
||||
}
|
||||
|
||||
// Semantic action colors (also theme-aware)
|
||||
.primary {
|
||||
background: var(--primary-background);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background: var(--danger-background);
|
||||
color: var(--danger-foreground);
|
||||
}
|
||||
|
||||
.success {
|
||||
background: var(--success-background);
|
||||
color: var(--success-foreground);
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: var(--warning-background);
|
||||
color: var(--warning-foreground);
|
||||
}
|
||||
|
||||
// Accent colors (for highlights, badges, etc.)
|
||||
.accent {
|
||||
background: var(--accent-primary); // robin blue
|
||||
background: var(--accent-forest); // green
|
||||
background: var(--accent-cherry); // red
|
||||
background: var(--accent-amber); // yellow
|
||||
}
|
||||
```
|
||||
|
||||
**Token hierarchy:**
|
||||
- Primitive tokens (`--bg-*`, `--text-*`, etc.) have fixed values across themes.
|
||||
- Semantic tokens (L1/L2/L3, `--primary-*`, `--danger-*`, etc.) automatically swap based on theme.
|
||||
- L1 = base/root layer
|
||||
- L2 = elevated surfaces (cards, panels)
|
||||
- L3 = nested/inset elements
|
||||
|
||||
## Overriding @signozhq/ui Components
|
||||
|
||||
Components expose CSS variables for customization.
|
||||
|
||||
You can ensure they exist by looking at ./node_modules/@signozhq/ui/dist.
|
||||
Never write a override without confirm it exists.
|
||||
|
||||
Override via:
|
||||
|
||||
### Method 1: CSS Variables (Preferred)
|
||||
|
||||
Each component exposes `--<component>-<property>` variables:
|
||||
|
||||
```scss
|
||||
// Override Button
|
||||
.customButton {
|
||||
--button-background: var(--success-background);
|
||||
--button-border-radius: var(--radius-2);
|
||||
--button-padding: var(--spacing-4) var(--spacing-8);
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
// Override Input
|
||||
.customInput {
|
||||
--input-height: 2.5rem;
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-padding: var(--spacing-2) var(--spacing-6);
|
||||
--input-placeholder-color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// Override nested parts
|
||||
.customInput {
|
||||
--input-prefix-padding: 0 var(--spacing-4) 0 var(--spacing-6);
|
||||
--input-suffix-color: var(--accent-primary);
|
||||
}
|
||||
```
|
||||
|
||||
### Method 2: Data Attributes
|
||||
|
||||
Components use data attributes for variants/states. Target them for state-specific overrides:
|
||||
|
||||
```scss
|
||||
// Target variant
|
||||
.wrapper :global([data-variant="outlined"]) {
|
||||
--button-border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
// Target size
|
||||
.wrapper :global([data-size="sm"]) {
|
||||
--button-font-size: var(--periscope-font-size-small);
|
||||
}
|
||||
|
||||
// Target color
|
||||
.wrapper :global([data-color="destructive"]) {
|
||||
--button-background: var(--danger-background);
|
||||
}
|
||||
|
||||
// Target state (Radix patterns)
|
||||
.popover :global([data-state="open"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tooltip :global([data-side="top"]) {
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
```
|
||||
|
||||
### Common Component CSS Variables
|
||||
|
||||
**Button:**
|
||||
- `--button-background`, `--button-border-radius`, `--button-padding`
|
||||
- `--button-font-size`, `--button-height`, `--button-gap`
|
||||
- `--button-hover-background`, `--button-disabled-opacity`
|
||||
|
||||
**Input:**
|
||||
- `--input-height`, `--input-border-color`, `--input-background`
|
||||
- `--input-padding`, `--input-font-size`, `--input-placeholder-color`
|
||||
- `--input-focus-outline-color`, `--input-hover-border-color`
|
||||
- `--input-prefix-*`, `--input-suffix-*` for adornments
|
||||
|
||||
**General pattern:** `--<component>-<property>` or `--<component>-<state>-<property>`
|
||||
|
||||
## Good Patterns
|
||||
|
||||
### Structure
|
||||
|
||||
```scss
|
||||
// Flat, descriptive, component-scoped
|
||||
.alertHistory { }
|
||||
.alertHistoryHeader { }
|
||||
.alertHistoryContent { }
|
||||
.alertHistoryFooter { }
|
||||
|
||||
// State modifiers as separate classes
|
||||
.alertHistory { }
|
||||
.alertHistoryLoading { }
|
||||
.alertHistoryEmpty { }
|
||||
.alertHistoryError { }
|
||||
```
|
||||
|
||||
### Composition
|
||||
|
||||
```scss
|
||||
// GOOD: Composing styles
|
||||
.baseButton {
|
||||
padding: var(--spacing-2) var(--spacing-4);
|
||||
border-radius: var(--radius-2);
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
composes: baseButton;
|
||||
background: var(--primary-background);
|
||||
}
|
||||
```
|
||||
|
||||
### Pseudo Elements
|
||||
|
||||
```scss
|
||||
.button {
|
||||
// States
|
||||
&:hover { opacity: 0.9; }
|
||||
&:focus { outline: 2px solid var(--ring); outline-offset: 2px; }
|
||||
&:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
// Pseudo elements
|
||||
&::before { content: ''; }
|
||||
&::after { content: ''; }
|
||||
}
|
||||
```
|
||||
|
||||
### Media Queries
|
||||
|
||||
```scss
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## JS Import Patterns
|
||||
|
||||
```tsx
|
||||
// GOOD
|
||||
import styles from './Component.module.scss';
|
||||
|
||||
<div className={styles.container}>
|
||||
<span className={styles.title}>Title</span>
|
||||
</div>
|
||||
|
||||
// GOOD: Conditional classes
|
||||
<div className={`${styles.button} ${isActive ? styles.buttonActive : ''}`}>
|
||||
|
||||
// GOOD: With clsx/classnames
|
||||
<div className={clsx(styles.button, { [styles.buttonActive]: isActive })}>
|
||||
|
||||
// BAD: Bracket access (may be undefined)
|
||||
<div className={styles['button-active']}> // undefined if CSS has .button-active
|
||||
|
||||
// BAD: String interpolation for class names
|
||||
<div className={`${styles.button}-active`}> // won't work
|
||||
```
|
||||
|
||||
## Checklist Before Committing
|
||||
|
||||
- [ ] All class names use camelCase in CSS
|
||||
- [ ] No bracket access (`styles['...']`) in JS unless verified
|
||||
- [ ] No deep class nesting (max 3 class levels; pseudo-classes/elements and parent-reference selectors like `&.active`, `&#bar` are not counted)
|
||||
- [ ] No hardcoded colors - use `--l1/l2/l3-*` semantic tokens (not `--bg-*` primitives)
|
||||
- [ ] No magic numbers - use `--spacing-*` tokens
|
||||
- [ ] Typography uses `--periscope-font-size-*` or `--font-size-*` tokens
|
||||
- [ ] @signozhq/ui overrides use CSS variables, not direct class overrides
|
||||
- [ ] Global escapes only for third-party overrides
|
||||
- [ ] No ID selectors
|
||||
- [ ] No bare element selectors
|
||||
|
||||
## Lint Rules
|
||||
|
||||
### JS/TS (oxlint)
|
||||
|
||||
| Rule | Severity | Catches |
|
||||
|------|----------|---------|
|
||||
| `signoz/no-css-module-bracket-access` | warn | `styles['kebab-case']`, dynamic access |
|
||||
|
||||
### CSS/SCSS (stylelint)
|
||||
|
||||
| Rule | Severity | Catches |
|
||||
|------|----------|---------|
|
||||
| `local/no-deep-nesting` | warning | class nesting >3 levels (pseudo-classes/elements and parent-reference selectors `&.foo`, `&#bar` not counted; configurable via `maxDepth` secondary option) |
|
||||
| `local/no-id-selectors` | error | `#id` selectors |
|
||||
| `local/no-bare-element-selectors` | error | root-level `div`, `span` etc |
|
||||
| `local/prefer-css-variables` | warning | hardcoded colors |
|
||||
| `local/class-name-pattern` | warning | kebab-case, snake_case, PascalCase |
|
||||
|
||||
Run: `pnpm lint:styles` to check CSS modules.
|
||||
@@ -1,144 +0,0 @@
|
||||
/**
|
||||
* Rule: no-css-module-bracket-access
|
||||
*
|
||||
* Prevents bracket access on CSS module imports that may fail with camelCaseOnly config.
|
||||
*
|
||||
* With Vite's `localsConvention: 'camelCaseOnly'`, kebab-case class names are
|
||||
* converted to camelCase and the original key is NOT exported.
|
||||
*
|
||||
* This rule catches patterns like:
|
||||
* styles['my-class'] // BAD - undefined if CSS has .my-class
|
||||
* styles['myClass'] // OK but prefer dot notation
|
||||
* styles.myClass // GOOD
|
||||
*
|
||||
* Catches:
|
||||
* - Bracket access with kebab-case strings (always fails)
|
||||
* - Bracket access with any string literal (warn - prefer dot notation)
|
||||
* - Dynamic bracket access (warn - risky)
|
||||
*/
|
||||
|
||||
const CSS_MODULE_IMPORT_NAMES = new Set([
|
||||
'styles',
|
||||
'classes',
|
||||
'css',
|
||||
'classNames',
|
||||
]);
|
||||
|
||||
function looksLikeCssModuleImport(name) {
|
||||
// Common patterns: styles, componentStyles, alertHistoryStyles
|
||||
return (
|
||||
CSS_MODULE_IMPORT_NAMES.has(name) ||
|
||||
name.endsWith('Styles') ||
|
||||
name.endsWith('Classes') ||
|
||||
name.endsWith('Css')
|
||||
);
|
||||
}
|
||||
|
||||
function isKebabCase(str) {
|
||||
return str.includes('-');
|
||||
}
|
||||
|
||||
function isSnakeCase(str) {
|
||||
return str.includes('_');
|
||||
}
|
||||
|
||||
export default {
|
||||
create(context) {
|
||||
return {
|
||||
MemberExpression(node) {
|
||||
// Only check bracket notation: styles['...']
|
||||
if (!node.computed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const object = node.object;
|
||||
if (object.type !== 'Identifier') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this looks like a CSS module import
|
||||
if (!looksLikeCssModuleImport(object.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const property = node.property;
|
||||
|
||||
// Dynamic access: styles[variable]
|
||||
if (property.type === 'Identifier') {
|
||||
context.report({
|
||||
node,
|
||||
message: `Dynamic CSS module access '${object.name}[${property.name}]' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify the key exists.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Template literal: styles[\`...\`]
|
||||
if (property.type === 'TemplateLiteral') {
|
||||
context.report({
|
||||
node,
|
||||
message: `Template literal CSS module access is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Prefer dot notation.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Numeric / boolean / null literal: styles[0]. Not a class lookup; ignore.
|
||||
if (property.type === 'Literal' && typeof property.value !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// String literal: styles['...']
|
||||
if (property.type === 'Literal' && typeof property.value === 'string') {
|
||||
const className = property.value;
|
||||
|
||||
// Kebab-case will definitely fail
|
||||
if (isKebabCase(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `CSS module class '${className}' uses kebab-case which won't work with 'camelCaseOnly' config. Use '${object.name}.${toCamelCase(className)}' instead.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Snake_case is suspicious
|
||||
if (isSnakeCase(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `CSS module class '${className}' uses snake_case which may not work as expected. Prefer camelCase: '${object.name}.${toCamelCase(className)}'.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid camelCase but using bracket notation - prefer dot
|
||||
if (/^[a-z][a-zA-Z0-9]*$/.test(className)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `Prefer dot notation: '${object.name}.${className}' instead of '${object.name}['${className}']'.`,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Catch-all for other dynamic expressions:
|
||||
// styles['prefix' + suffix] (BinaryExpression)
|
||||
// styles[isActive && 'foo'] (LogicalExpression)
|
||||
// styles[isActive ? 'a' : 'b'] (ConditionalExpression)
|
||||
// styles[fn()] (CallExpression)
|
||||
context.report({
|
||||
node,
|
||||
message: `Dynamic CSS module access on '${object.name}' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify each key resolves to an exported camelCase class.`,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function toCamelCase(str) {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map((part, i) =>
|
||||
i === 0
|
||||
? part.toLowerCase()
|
||||
: part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs'
|
||||
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
|
||||
import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -24,6 +23,5 @@ export default {
|
||||
'no-raw-absolute-path': noRawAbsolutePath,
|
||||
'no-antd-components': noAntdComponents,
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,13 +3,36 @@ import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
|
||||
export interface DayBreakdownEntry {
|
||||
timestamp: number;
|
||||
total: number;
|
||||
quantity: number;
|
||||
count: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface TierEntry {
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
tierCost: number;
|
||||
}
|
||||
|
||||
export interface BreakdownEntry {
|
||||
type: string;
|
||||
unit: string;
|
||||
dayWiseBreakdown: {
|
||||
breakdown: DayBreakdownEntry[];
|
||||
};
|
||||
tiers?: TierEntry[];
|
||||
}
|
||||
|
||||
export interface UsageResponsePayloadProps {
|
||||
billingPeriodStart: Date;
|
||||
billingPeriodEnd: Date;
|
||||
billingPeriodStart: number;
|
||||
billingPeriodEnd: number;
|
||||
details: {
|
||||
total: number;
|
||||
baseFee: number;
|
||||
breakdown: [];
|
||||
breakdown: BreakdownEntry[];
|
||||
billTotal: number;
|
||||
};
|
||||
discount: number;
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import refreshPaymentStatus from 'api/v3/licenses/put';
|
||||
import cx from 'classnames';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { RefreshCcw } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
function RefreshPaymentStatus({
|
||||
btnShape,
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
btnShape?: 'default' | 'round' | 'circle';
|
||||
btnShape?: 'default' | 'round';
|
||||
type?: 'button' | 'text' | 'tooltip';
|
||||
className?: string;
|
||||
}): JSX.Element {
|
||||
const { t } = useTranslation(['failedPayment']);
|
||||
const { activeLicenseRefetch } = useAppContext();
|
||||
@@ -31,26 +33,35 @@ function RefreshPaymentStatus({
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant={type === 'text' ? 'ghost' : 'outlined'}
|
||||
color="secondary"
|
||||
size="md"
|
||||
style={btnShape === 'round' ? { borderRadius: '9999px' } : undefined}
|
||||
className={className}
|
||||
onClick={handleRefreshPaymentStatus}
|
||||
prefix={<RefreshCcw size={14} />}
|
||||
loading={isLoading}
|
||||
>
|
||||
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="refresh-payment-status-btn-wrapper">
|
||||
<Tooltip title={type === 'tooltip' ? t('refreshPaymentStatus') : ''}>
|
||||
<Button
|
||||
type={type === 'text' ? 'text' : 'default'}
|
||||
shape={btnShape}
|
||||
className={cx('periscope-btn', { text: type === 'text' })}
|
||||
onClick={handleRefreshPaymentStatus}
|
||||
icon={<RefreshCcw size={14} />}
|
||||
loading={isLoading}
|
||||
>
|
||||
{type !== 'tooltip' ? t('refreshPaymentStatus') : ''}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{type === 'tooltip' ? (
|
||||
<TooltipSimple title={t('refreshPaymentStatus')}>{button}</TooltipSimple>
|
||||
) : (
|
||||
button
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
RefreshPaymentStatus.defaultProps = {
|
||||
btnShape: 'default',
|
||||
type: 'button',
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default RefreshPaymentStatus;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
.billingContainer {
|
||||
margin-bottom: var(--spacing-20);
|
||||
padding-top: 36px;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
|
||||
.pageHeader {
|
||||
margin-bottom: var(--spacing-8);
|
||||
|
||||
.pageHeaderTitle {
|
||||
font-weight: var(--label-medium-500-font-weight);
|
||||
font-size: var(--label-medium-500-font-size);
|
||||
line-height: 32px;
|
||||
letter-spacing: -0.08px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.pageHeaderSubtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.pageInfoTitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.pageInfoSubtitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
:global(.ant-card) {
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.billingManageBtn {
|
||||
background: var(--l3-background);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.billingSummary {
|
||||
margin: var(--spacing-12) var(--spacing-4);
|
||||
}
|
||||
|
||||
.billingDetails {
|
||||
margin: var(--spacing-12) 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
:global {
|
||||
.ant-table {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
height: 52px;
|
||||
padding: 0 var(--padding-4);
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
height: 52px;
|
||||
padding: 0 var(--padding-4);
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&:first-child {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
font-feature-settings:
|
||||
'zero' 1,
|
||||
'lnum' 1,
|
||||
'tnum' 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:last-child > td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: var(--l2-background) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.billingDetailsHeaderCell {
|
||||
position: relative;
|
||||
background: var(--l2-background) !important;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid var(--l1-border) !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 2px;
|
||||
background: var(--l2-background);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upgradePlanBenefits {
|
||||
margin: 0 var(--spacing-4);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 5px;
|
||||
padding: 0 var(--padding-12);
|
||||
|
||||
.planBenefits {
|
||||
.planBenefit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.billingGraphSection {
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
.billingGraphFooter {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--padding-3) var(--padding-4);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
|
||||
.billingFooterBtn {
|
||||
background: var(--l3-background);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emptyGraphCard {
|
||||
:global(.ant-card-body) {
|
||||
height: 40vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.billingUpdateNote {
|
||||
margin-top: var(--spacing-8);
|
||||
font-family: var(--font-family-inter);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
:global {
|
||||
.ant-skeleton.ant-skeleton-element.ant-skeleton-active {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.ant-skeleton.ant-skeleton-element .ant-skeleton-input {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
.billing-container {
|
||||
margin-bottom: 40px;
|
||||
padding-top: 36px;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
|
||||
.billing-summary {
|
||||
margin: 24px 8px;
|
||||
}
|
||||
|
||||
.billing-details {
|
||||
margin: 24px 0px;
|
||||
|
||||
.ant-table-title {
|
||||
color: var(--l2-foreground);
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
|
||||
.ant-table-cell {
|
||||
background-color: var(--l1-background);
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-table-tbody {
|
||||
td {
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-plan-benefits {
|
||||
margin: 0px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 5px;
|
||||
padding: 0 48px;
|
||||
.plan-benefits {
|
||||
.plan-benefit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-graph-card {
|
||||
.ant-card-body {
|
||||
height: 40vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.billing-update-note {
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-skeleton.ant-skeleton-element.ant-skeleton-active {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.ant-skeleton.ant-skeleton-element .ant-skeleton-input {
|
||||
min-width: 100% !important;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ describe('BillingContainer', () => {
|
||||
});
|
||||
expect(pricePerUnit).toBeInTheDocument();
|
||||
const cost = await screen.findByRole('columnheader', {
|
||||
name: /cost \(billing period to date\)/i,
|
||||
name: /cost/i,
|
||||
});
|
||||
expect(cost).toBeInTheDocument();
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { CircleCheck, CloudDownload } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { CircleCheck, Landmark, MonitorDown } from '@signozhq/icons';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Flex,
|
||||
@@ -16,7 +15,10 @@ import {
|
||||
TableColumnsType as ColumnsType,
|
||||
} from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import getUsage, { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import getUsage, {
|
||||
BreakdownEntry,
|
||||
UsageResponsePayloadProps,
|
||||
} from 'api/billing/getUsage';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
import manageCreditCardApi from 'api/v1/portal/create';
|
||||
@@ -29,7 +31,7 @@ import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isEmpty, pick } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SuccessResponseV2 } from 'types/api';
|
||||
import { ErrorResponse, SuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
|
||||
@@ -38,7 +40,7 @@ import CancelSubscriptionBanner from './CancelSubscriptionBanner';
|
||||
import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph';
|
||||
import { prepareCsvData } from './BillingUsageGraph/utils';
|
||||
|
||||
import './BillingContainer.styles.scss';
|
||||
import styles from './BillingContainer.module.scss';
|
||||
import { LicenseState } from 'types/api/licensesV3/getActive';
|
||||
|
||||
interface DataType {
|
||||
@@ -115,7 +117,7 @@ const dummyColumns: ColumnsType<DataType> = [
|
||||
render: renderSkeletonInput,
|
||||
},
|
||||
{
|
||||
title: 'Cost (Billing period to date)',
|
||||
title: 'Cost',
|
||||
dataIndex: 'cost',
|
||||
key: 'cost',
|
||||
render: renderSkeletonInput,
|
||||
@@ -130,7 +132,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
const [billAmount, setBillAmount] = useState(0);
|
||||
const [daysRemaining, setDaysRemaining] = useState(0);
|
||||
const [isFreeTrial, setIsFreeTrial] = useState(false);
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [data, setData] = useState<DataType[]>([]);
|
||||
const [apiResponse, setApiResponse] = useState<
|
||||
Partial<UsageResponsePayloadProps>
|
||||
>({});
|
||||
@@ -150,7 +152,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
const processUsageData = useCallback(
|
||||
(data: any): void => {
|
||||
(data: SuccessResponse<UsageResponsePayloadProps> | ErrorResponse): void => {
|
||||
if (isEmpty(data?.payload)) {
|
||||
return;
|
||||
}
|
||||
@@ -158,27 +160,23 @@ export default function BillingContainer(): JSX.Element {
|
||||
details: { breakdown = [], billTotal },
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
} = data?.payload || {};
|
||||
const formattedUsageData: any[] = [];
|
||||
} = (data as SuccessResponse<UsageResponsePayloadProps>).payload;
|
||||
const formattedUsageData: DataType[] = [];
|
||||
|
||||
if (breakdown && Array.isArray(breakdown)) {
|
||||
for (let index = 0; index < breakdown.length; index += 1) {
|
||||
const element = breakdown[index];
|
||||
const element: BreakdownEntry = breakdown[index];
|
||||
|
||||
element?.tiers.forEach(
|
||||
(
|
||||
tier: { quantity: number; unitPrice: number; tierCost: number },
|
||||
i: number,
|
||||
) => {
|
||||
formattedUsageData.push({
|
||||
key: `${index}${i}`,
|
||||
name: i === 0 ? element?.type : '',
|
||||
dataIngested: `${tier.quantity} ${element?.unit}`,
|
||||
pricePerUnit: tier.unitPrice,
|
||||
cost: `$ ${tier.tierCost}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
element?.tiers?.forEach((tier, i: number) => {
|
||||
formattedUsageData.push({
|
||||
key: `${index}${i}`,
|
||||
name: i === 0 ? element?.type : '',
|
||||
unit: element?.unit ?? '',
|
||||
dataIngested: `${tier.quantity} ${element?.unit}`,
|
||||
pricePerUnit: String(tier.unitPrice),
|
||||
cost: `$ ${tier.tierCost}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,16 +249,19 @@ export default function BillingContainer(): JSX.Element {
|
||||
title: 'Data Ingested',
|
||||
dataIndex: 'dataIngested',
|
||||
key: 'dataIngested',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: 'Price per Unit',
|
||||
dataIndex: 'pricePerUnit',
|
||||
key: 'pricePerUnit',
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: 'Cost (Billing period to date)',
|
||||
title: 'Cost',
|
||||
dataIndex: 'cost',
|
||||
key: 'cost',
|
||||
align: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -345,23 +346,6 @@ export default function BillingContainer(): JSX.Element {
|
||||
updateCreditCard,
|
||||
]);
|
||||
|
||||
const BillingUsageGraphCallback = useCallback(
|
||||
() =>
|
||||
!isLoading && !isFetchingBillingData ? (
|
||||
<>
|
||||
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
|
||||
<div className="billing-update-note">
|
||||
Note: Billing metrics are updated once every 24 hours.
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Card className="empty-graph-card" bordered={false}>
|
||||
<Spinner size="large" tip="Loading..." height="35vh" />
|
||||
</Card>
|
||||
),
|
||||
[apiResponse, billAmount, isLoading, isFetchingBillingData],
|
||||
);
|
||||
|
||||
const subscriptionPastDueMessage = (): JSX.Element => (
|
||||
<Typography>
|
||||
{`We were not able to process payments for your account. Please update your card details `}
|
||||
@@ -415,12 +399,12 @@ export default function BillingContainer(): JSX.Element {
|
||||
trialInfo?.gracePeriodEnd;
|
||||
|
||||
return (
|
||||
<div className="billing-container">
|
||||
<Flex vertical style={{ marginBottom: 16 }}>
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 18 }}>
|
||||
<div className={styles.billingContainer}>
|
||||
<Flex vertical gap={4} className={styles.pageHeader}>
|
||||
<Typography.Text className={styles.pageHeaderTitle}>
|
||||
{t('billing')}
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
<Typography.Text className={styles.pageHeaderSubtitle}>
|
||||
{t('manage_billing_and_costs')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
@@ -428,50 +412,36 @@ export default function BillingContainer(): JSX.Element {
|
||||
<Card
|
||||
bordered={false}
|
||||
style={{ minHeight: 150, marginBottom: 16 }}
|
||||
className="page-info"
|
||||
className={styles.pageInfo}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Flex vertical>
|
||||
<Typography.Title level={5} style={{ marginTop: 2, fontWeight: 500 }}>
|
||||
<Flex vertical gap={8}>
|
||||
<p className={styles.pageInfoTitle}>
|
||||
{isCloudUserVal ? t('teams_cloud') : t('teams')}{' '}
|
||||
{isFreeTrial ? <Badge color="success"> Free Trial </Badge> : ''}
|
||||
</Typography.Title>
|
||||
</p>
|
||||
|
||||
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage ? (
|
||||
<Typography.Text style={{ fontSize: 12, color: Color.BG_VANILLA_400 }}>
|
||||
<p className={styles.pageInfoSubtitle}>
|
||||
{daysRemaining} {daysRemainingStr}
|
||||
</Typography.Text>
|
||||
</p>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Flex gap={8}>
|
||||
<Button
|
||||
type="default"
|
||||
size="middle"
|
||||
loading={isLoadingBilling || isLoadingManageBilling}
|
||||
disabled={isLoading || isFetchingBillingData}
|
||||
onClick={handleCsvDownload}
|
||||
className="periscope-btn"
|
||||
>
|
||||
<Flex align="center" justify="center" gap={4}>
|
||||
<CloudDownload size="md" />
|
||||
Download CSV
|
||||
</Flex>
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="header-billing-button"
|
||||
type="primary"
|
||||
size="middle"
|
||||
loading={isLoadingBilling || isLoadingManageBilling}
|
||||
disabled={isLoading}
|
||||
onClick={handleBilling}
|
||||
>
|
||||
{trialInfo?.trialConvertedToSubscription
|
||||
? t('manage_billing')
|
||||
: t('upgrade_plan')}
|
||||
</Button>
|
||||
|
||||
<RefreshPaymentStatus type="tooltip" />
|
||||
</Flex>
|
||||
<Button
|
||||
testId="header-billing-button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
loading={isLoadingBilling || isLoadingManageBilling}
|
||||
disabled={isLoading}
|
||||
onClick={handleBilling}
|
||||
prefix={<Landmark size={14} />}
|
||||
className={styles.billingManageBtn}
|
||||
>
|
||||
{trialInfo?.trialConvertedToSubscription
|
||||
? t('manage_billing')
|
||||
: t('upgrade_plan')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{trialInfo?.onTrial && trialInfo?.trialConvertedToSubscription && (
|
||||
@@ -485,8 +455,8 @@ export default function BillingContainer(): JSX.Element {
|
||||
|
||||
{!isLoading && !isFetchingBillingData && !showGracePeriodMessage
|
||||
? headerText && (
|
||||
<Alert
|
||||
message={headerText}
|
||||
<Callout
|
||||
title={headerText}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
@@ -503,8 +473,8 @@ export default function BillingContainer(): JSX.Element {
|
||||
billingData &&
|
||||
trialInfo?.gracePeriodEnd &&
|
||||
showGracePeriodMessage ? (
|
||||
<Alert
|
||||
message={`Your data is safe with us until ${getFormattedDate(
|
||||
<Callout
|
||||
title={`Your data is safe with us until ${getFormattedDate(
|
||||
trialInfo?.gracePeriodEnd || Date.now(),
|
||||
)}. Please upgrade plan now to retain your data.`}
|
||||
type="info"
|
||||
@@ -515,26 +485,69 @@ export default function BillingContainer(): JSX.Element {
|
||||
|
||||
{isSubscriptionPastDue &&
|
||||
(!isLoading && !isFetchingBillingData ? (
|
||||
<Alert
|
||||
message={subscriptionPastDueMessage()}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
<Callout type="error" showIcon style={{ marginTop: 12 }}>
|
||||
{subscriptionPastDueMessage()}
|
||||
</Callout>
|
||||
) : (
|
||||
<Skeleton.Input active style={{ height: 20, marginTop: 20 }} />
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<BillingUsageGraphCallback />
|
||||
<div className={styles.billingGraphSection}>
|
||||
{!isLoading && !isFetchingBillingData ? (
|
||||
<BillingUsageGraph data={apiResponse} billAmount={billAmount} />
|
||||
) : (
|
||||
<Card className={styles.emptyGraphCard} bordered={false}>
|
||||
<Spinner size="large" tip="Loading..." height="35vh" />
|
||||
</Card>
|
||||
)}
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<div className={styles.billingGraphFooter}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={handleCsvDownload}
|
||||
prefix={<MonitorDown size={14} />}
|
||||
testId="download-csv-button"
|
||||
className={styles.billingFooterBtn}
|
||||
>
|
||||
Download CSV
|
||||
</Button>
|
||||
<RefreshPaymentStatus type="button" className={styles.billingFooterBtn} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Callout type="info" size="small" className={styles.billingUpdateNote}>
|
||||
Billing metrics are updated once every 24 hours.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className="billing-details">
|
||||
<div className={styles.billingDetails}>
|
||||
{!isLoading && !isFetchingBillingData && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
components={{
|
||||
header: {
|
||||
cell: ({
|
||||
style,
|
||||
...props
|
||||
}: React.ThHTMLAttributes<HTMLTableCellElement>): JSX.Element => {
|
||||
const { background: _, boxShadow: __, ...safeStyle } = style ?? {};
|
||||
return (
|
||||
<th
|
||||
{...props}
|
||||
style={safeStyle}
|
||||
className={`${props.className ?? ''} ${styles.billingDetailsHeaderCell}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -546,7 +559,7 @@ export default function BillingContainer(): JSX.Element {
|
||||
)}
|
||||
|
||||
{!trialInfo?.trialConvertedToSubscription && (
|
||||
<div className="upgrade-plan-benefits">
|
||||
<div className={styles.upgradePlanBenefits}>
|
||||
<Row
|
||||
justify="space-between"
|
||||
align="middle"
|
||||
@@ -555,16 +568,16 @@ export default function BillingContainer(): JSX.Element {
|
||||
}}
|
||||
gutter={[16, 16]}
|
||||
>
|
||||
<Col span={20} className="plan-benefits">
|
||||
<Typography.Text className="plan-benefit">
|
||||
<Col span={20} className={styles.planBenefits}>
|
||||
<Typography.Text className={styles.planBenefit}>
|
||||
<CircleCheck size="md" />
|
||||
{t('upgrade_now_text')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="plan-benefit">
|
||||
<Typography.Text className={styles.planBenefit}>
|
||||
<CircleCheck size="md" />
|
||||
{t('Your billing will start only after the trial period')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="plan-benefit">
|
||||
<Typography.Text className={styles.planBenefit}>
|
||||
<CircleCheck size="md" />
|
||||
<span>
|
||||
{t('checkout_plans')}
|
||||
@@ -583,9 +596,10 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Col>
|
||||
<Col span={4} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
data-testid="upgrade-plan-button"
|
||||
type="primary"
|
||||
size="middle"
|
||||
testId="upgrade-plan-button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoadingBilling || isLoadingManageBilling}
|
||||
onClick={handleBilling}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.headerRow {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.itemList {
|
||||
overflow-y: auto;
|
||||
max-height: 300px;
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import TooltipHeader from 'lib/uPlotV2/components/Tooltip/components/TooltipHeader/TooltipHeader';
|
||||
import TooltipItem from 'lib/uPlotV2/components/Tooltip/components/TooltipItem/TooltipItem';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
|
||||
import {
|
||||
TooltipContentItem,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import TooltipStyles from 'lib/uPlotV2/components/Tooltip/Tooltip.module.scss';
|
||||
import Styles from './BillingBarChartTooltip.module.scss';
|
||||
|
||||
interface BillingBarChartTooltipProps extends TooltipRenderArgs {
|
||||
billingApiResponse: MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
export function BillingBarChartTooltip({
|
||||
billingApiResponse,
|
||||
uPlotInstance,
|
||||
dataIndexes,
|
||||
seriesIndex,
|
||||
isPinned,
|
||||
}: BillingBarChartTooltipProps): JSX.Element {
|
||||
const content = useMemo((): TooltipContentItem[] => {
|
||||
const baseItems = buildTooltipContent({
|
||||
data: uPlotInstance.data,
|
||||
series: uPlotInstance.series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex: seriesIndex,
|
||||
uPlotInstance,
|
||||
yAxisUnit: '',
|
||||
isStackedBarChart: true,
|
||||
});
|
||||
|
||||
return baseItems.map((item) => {
|
||||
const match = billingApiResponse.data.result.find(
|
||||
(r) => (r.legend || r.queryName) === item.label,
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const seriesIdx = uPlotInstance.series.findIndex(
|
||||
(s) => s.label === item.label,
|
||||
);
|
||||
if (seriesIdx === -1) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const dataIndex = dataIndexes[seriesIdx];
|
||||
const quantity = dataIndex != null ? match.quantity?.[dataIndex] : null;
|
||||
const unit = match.unit ?? '';
|
||||
const quantityStr =
|
||||
quantity != null ? ` - ${getToolTipValue(quantity)} ${unit}` : '';
|
||||
|
||||
return {
|
||||
...item,
|
||||
tooltipValue: `$${getToolTipValue(item.value, '')}${quantityStr}`,
|
||||
};
|
||||
});
|
||||
}, [uPlotInstance, seriesIndex, dataIndexes, billingApiResponse]);
|
||||
|
||||
const activeItem = content.find((item) => item.isActive) ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(TooltipStyles.container, {
|
||||
[TooltipStyles.pinned]: isPinned,
|
||||
})}
|
||||
data-testid="uplot-tooltip-container"
|
||||
>
|
||||
<TooltipHeader
|
||||
uPlotInstance={uPlotInstance}
|
||||
showTooltipHeader
|
||||
isPinned={isPinned}
|
||||
activeItem={null}
|
||||
headerRowClassName={Styles.headerRow}
|
||||
dateFormat={DATE_TIME_FORMATS.MONTH_DATE}
|
||||
/>
|
||||
{activeItem != null && <span className={TooltipStyles.divider} />}
|
||||
<div className={Styles.itemList} data-testid="uplot-tooltip-list">
|
||||
{content.map((item) => (
|
||||
<TooltipItem key={item.label} item={item} isItemActive={item.isActive} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.graphContainer {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.billingGraphCard {
|
||||
:global {
|
||||
.uplot-no-data {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
height: 40vh;
|
||||
|
||||
.uplot-graph-container {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.totalSpent {
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.totalSpentTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0.48px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.billing-graph-card {
|
||||
.ant-card-body {
|
||||
height: 40vh;
|
||||
.uplot-graph-container {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
.total-spent {
|
||||
font-family: 'SF Mono' monospace;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.total-spent-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
letter-spacing: 0.48px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +1,146 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { Card, Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import Uplot from 'components/Uplot';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import tooltipPlugin from 'lib/uPlotLib/plugins/tooltipPlugin';
|
||||
import getAxes from 'lib/uPlotLib/utils/getAxes';
|
||||
import getRenderer from 'lib/uPlotLib/utils/getRenderer';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { getXAxisScale } from 'lib/uPlotLib/utils/getXAxisScale';
|
||||
import { getYAxisScale } from 'lib/uPlotLib/utils/getYAxisScale';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import type uPlot from 'uplot';
|
||||
import type { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
|
||||
import { BillingBarChartTooltip } from './BillingBarChartTooltip';
|
||||
import { prepareBillingBarConfig } from './prepareBillingBarConfig';
|
||||
import {
|
||||
calculateStartEndTime,
|
||||
convertDataToMetricRangePayload,
|
||||
fillMissingValuesForQuantities,
|
||||
} from './utils';
|
||||
|
||||
import './BillingUsageGraph.styles.scss';
|
||||
import '../../../lib/uPlotLib/uPlotLib.styles.scss';
|
||||
import styles from './BillingUsageGraph.module.scss';
|
||||
|
||||
interface BillingUsageGraphProps {
|
||||
data: any;
|
||||
data: Partial<UsageResponsePayloadProps>;
|
||||
billAmount: number;
|
||||
}
|
||||
const paths = (
|
||||
u: any,
|
||||
seriesIdx: number,
|
||||
idx0: number,
|
||||
idx1: number,
|
||||
extendGap: boolean,
|
||||
buildClip: boolean,
|
||||
): uPlot.Series.PathBuilder => {
|
||||
const s = u.series[seriesIdx];
|
||||
const style = s.drawStyle;
|
||||
const interp = s.lineInterpolation;
|
||||
|
||||
const renderer = getRenderer(style, interp);
|
||||
|
||||
return renderer(u, seriesIdx, idx0, idx1, extendGap, buildClip);
|
||||
};
|
||||
|
||||
const calculateStartEndTime = (
|
||||
data: any,
|
||||
): { startTime: number; endTime: number } => {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown: any) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry: any) => {
|
||||
timestamps.push(entry?.timestamp);
|
||||
});
|
||||
});
|
||||
const billingTime = [data?.billingPeriodStart, data?.billingPeriodEnd];
|
||||
const startTime: number = Math.min(...timestamps, ...billingTime);
|
||||
const endTime: number = Math.max(...timestamps, ...billingTime);
|
||||
return { startTime, endTime };
|
||||
};
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
const { data, billAmount } = props;
|
||||
// Added this to fix the issue where breakdown with one day data are causing the bars to spread across multiple days
|
||||
data?.details?.breakdown?.forEach((breakdown: any) => {
|
||||
if (breakdown?.dayWiseBreakdown?.breakdown?.length === 1) {
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
total: 0,
|
||||
};
|
||||
breakdown.dayWiseBreakdown.breakdown.push(nextDay);
|
||||
}
|
||||
});
|
||||
const graphCompatibleData = useMemo(
|
||||
() => convertDataToMetricRangePayload(data),
|
||||
[data],
|
||||
);
|
||||
const chartData = getUPlotChartData(graphCompatibleData);
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() => calculateStartEndTime(data),
|
||||
[data],
|
||||
);
|
||||
|
||||
const getGraphSeries = (color: string, label: string): any => ({
|
||||
drawStyle: 'bars',
|
||||
paths,
|
||||
lineInterpolation: 'spline',
|
||||
show: true,
|
||||
label,
|
||||
fill: color,
|
||||
stroke: color,
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
points: {
|
||||
size: 5,
|
||||
show: false,
|
||||
stroke: color,
|
||||
},
|
||||
});
|
||||
|
||||
const uPlotSeries: any = useMemo(
|
||||
() => [
|
||||
{ label: 'Timestamp', stroke: 'purple' },
|
||||
getGraphSeries(
|
||||
'#7CEDBE',
|
||||
graphCompatibleData.data.result[0]?.legend as string,
|
||||
),
|
||||
getGraphSeries(
|
||||
'#4E74F8',
|
||||
graphCompatibleData.data.result[1]?.legend as string,
|
||||
),
|
||||
getGraphSeries(
|
||||
'#F24769',
|
||||
graphCompatibleData.data.result[2]?.legend as string,
|
||||
),
|
||||
],
|
||||
[graphCompatibleData.data.result],
|
||||
);
|
||||
|
||||
const axesOptions = getAxes({ isDarkMode, yAxisUnit: '' });
|
||||
|
||||
const optionsForChart: uPlot.Options = useMemo(
|
||||
() => ({
|
||||
id: 'billing-usage-breakdown',
|
||||
series: uPlotSeries,
|
||||
width: containerDimensions.width,
|
||||
height: containerDimensions.height - 30,
|
||||
axes: [
|
||||
{
|
||||
...axesOptions[0],
|
||||
grid: {
|
||||
...axesOptions.grid,
|
||||
show: false,
|
||||
stroke: isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400,
|
||||
},
|
||||
},
|
||||
{
|
||||
...axesOptions[1],
|
||||
stroke: isDarkMode ? Color.BG_SLATE_200 : Color.BG_INK_400,
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: {
|
||||
...getXAxisScale(startTime - 86400, endTime), // Minus 86400 from startTime to decrease a day to have a buffer start
|
||||
},
|
||||
y: {
|
||||
...getYAxisScale({
|
||||
series: graphCompatibleData?.data?.newResult?.data?.result,
|
||||
yAxisUnit: '',
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
}),
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
live: false,
|
||||
isolate: true,
|
||||
},
|
||||
cursor: {
|
||||
lock: false,
|
||||
focus: {
|
||||
prox: 1e6,
|
||||
bias: 1,
|
||||
},
|
||||
},
|
||||
focus: {
|
||||
alpha: 0.3,
|
||||
},
|
||||
padding: [32, 32, 16, 16],
|
||||
plugins: [
|
||||
tooltipPlugin({
|
||||
apiResponse: fillMissingValuesForQuantities(
|
||||
graphCompatibleData,
|
||||
chartData[0],
|
||||
),
|
||||
yAxisUnit: '',
|
||||
isBillingUsageGraphs: true,
|
||||
isDarkMode,
|
||||
// Single-day data causes bars to span multiple days — add a synthetic
|
||||
// zero-value next-day entry so uPlot renders a correctly-sized single-day bar.
|
||||
const normalizedData = useMemo(() => {
|
||||
if (!data?.details?.breakdown) {
|
||||
return data;
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
details: {
|
||||
...data.details,
|
||||
breakdown: data.details.breakdown.map((breakdown) => {
|
||||
if (breakdown?.dayWiseBreakdown?.breakdown?.length !== 1) {
|
||||
return breakdown;
|
||||
}
|
||||
const currentDay = breakdown.dayWiseBreakdown.breakdown[0];
|
||||
const nextDay = {
|
||||
...currentDay,
|
||||
timestamp: currentDay.timestamp + 86400,
|
||||
count: 0,
|
||||
size: 0,
|
||||
quantity: 0,
|
||||
total: 0,
|
||||
};
|
||||
return {
|
||||
...breakdown,
|
||||
dayWiseBreakdown: {
|
||||
...breakdown.dayWiseBreakdown,
|
||||
breakdown: [...breakdown.dayWiseBreakdown.breakdown, nextDay],
|
||||
},
|
||||
};
|
||||
}),
|
||||
],
|
||||
}),
|
||||
[
|
||||
axesOptions,
|
||||
chartData,
|
||||
containerDimensions.height,
|
||||
containerDimensions.width,
|
||||
endTime,
|
||||
graphCompatibleData,
|
||||
isDarkMode,
|
||||
startTime,
|
||||
uPlotSeries,
|
||||
],
|
||||
},
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const graphCompatibleData = useMemo(
|
||||
() => convertDataToMetricRangePayload(normalizedData),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US');
|
||||
const chartData = useMemo(
|
||||
() => prepareChartData(graphCompatibleData) as uPlot.AlignedData,
|
||||
[graphCompatibleData],
|
||||
);
|
||||
|
||||
const filledApiResponse = useMemo(
|
||||
(): MetricRangePayloadProps =>
|
||||
fillMissingValuesForQuantities(
|
||||
graphCompatibleData,
|
||||
chartData[0] as number[],
|
||||
),
|
||||
[graphCompatibleData, chartData],
|
||||
);
|
||||
|
||||
const { startTime, endTime } = useMemo(
|
||||
() =>
|
||||
calculateStartEndTime(normalizedData as Partial<UsageResponsePayloadProps>),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
prepareBillingBarConfig({
|
||||
isDarkMode,
|
||||
// Subtract 86400s (one day) from startTime to add a buffer before first bar
|
||||
minTimeScale: startTime !== undefined ? startTime - 86400 : undefined,
|
||||
maxTimeScale: endTime,
|
||||
apiResponse: graphCompatibleData,
|
||||
}),
|
||||
[isDarkMode, startTime, endTime, graphCompatibleData],
|
||||
);
|
||||
|
||||
const renderBillingTooltip = useCallback(
|
||||
(args: TooltipRenderArgs) => (
|
||||
<BillingBarChartTooltip billingApiResponse={filledApiResponse} {...args} />
|
||||
),
|
||||
[filledApiResponse],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card bordered={false} className="billing-graph-card">
|
||||
<Card bordered={false} className={styles.billingGraphCard}>
|
||||
<Flex justify="space-between">
|
||||
<Flex vertical gap={6}>
|
||||
<Typography.Text className="total-spent-title">
|
||||
<Typography.Text className={styles.totalSpentTitle}>
|
||||
TOTAL SPENT
|
||||
</Typography.Text>
|
||||
<Typography.Text className="total-spent">
|
||||
<Typography.Text className={styles.totalSpent}>
|
||||
${numberFormatter.format(billAmount)}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div ref={graphRef} style={{ height: '100%', paddingBottom: 48 }}>
|
||||
<Uplot data={chartData} options={optionsForChart} />
|
||||
<div ref={graphRef} className={styles.graphContainer}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
isStackedBarChart
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
customTooltip={renderBillingTooltip}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height - 30}
|
||||
canPinTooltip
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { BillingBarChartTooltip } from '../BillingBarChartTooltip';
|
||||
|
||||
// Mock buildTooltipContent so tests don't depend on uPlot stacking math
|
||||
jest.mock('lib/uPlotV2/components/Tooltip/utils', () => ({
|
||||
buildTooltipContent: jest.fn().mockReturnValue([
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 100,
|
||||
tooltipValue: '$100.00',
|
||||
color: '#7CEDBE',
|
||||
isActive: true,
|
||||
isHighlighted: false,
|
||||
},
|
||||
{
|
||||
label: 'Traces',
|
||||
value: 50,
|
||||
tooltipValue: '$50.00',
|
||||
color: '#4E74F8',
|
||||
isActive: false,
|
||||
isHighlighted: false,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
function makeUPlotInstance(seriesLabels: string[]): uPlot {
|
||||
return {
|
||||
data: [
|
||||
[1000, 2000],
|
||||
[100, 200],
|
||||
[50, 80],
|
||||
],
|
||||
cursor: { idx: 0 },
|
||||
series: [
|
||||
{ label: 'Timestamp', show: true, stroke: '#000' },
|
||||
...seriesLabels.map((label) => ({
|
||||
label,
|
||||
show: true,
|
||||
stroke: '#aabbcc',
|
||||
})),
|
||||
],
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function makeBillingApiResponse(
|
||||
entries: { legend: string; quantity: (number | null)[]; unit: string }[],
|
||||
): MetricRangePayloadProps {
|
||||
return {
|
||||
data: {
|
||||
result: entries.map((e) => ({
|
||||
legend: e.legend,
|
||||
queryName: e.legend,
|
||||
metric: {},
|
||||
values: [[1000, '10']] as [number, string][],
|
||||
quantity: e.quantity as number[],
|
||||
unit: e.unit,
|
||||
})),
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseTooltipArgs = {
|
||||
isPinned: false,
|
||||
dismiss: jest.fn(),
|
||||
viaSync: false,
|
||||
seriesIndex: 1,
|
||||
dataIndexes: [null, 0, 0],
|
||||
};
|
||||
|
||||
describe('BillingBarChartTooltip', () => {
|
||||
it('augments tooltipValue with quantity and unit for each series', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [1.5, 2.0], unit: 'GB' },
|
||||
{ legend: 'Traces', quantity: [500, 800], unit: 'spans' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/1\.5 GB/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/500 spans/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('omits quantity line when quantity at dataIndex is null', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [null, null], unit: 'GB' },
|
||||
{ legend: 'Traces', quantity: [null, null], unit: 'spans' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/null GB/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/null spans/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('uplot-tooltip-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats dollar value via getToolTipValue — strips trailing zeros (0.3076 → $0.3)', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs']);
|
||||
const { buildTooltipContent } = jest.requireMock(
|
||||
'lib/uPlotV2/components/Tooltip/utils',
|
||||
) as { buildTooltipContent: jest.Mock };
|
||||
buildTooltipContent.mockReturnValueOnce([
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 0.3076171875,
|
||||
tooltipValue: '$0.31',
|
||||
color: '#7CEDBE',
|
||||
isActive: true,
|
||||
isHighlighted: false,
|
||||
},
|
||||
]);
|
||||
const billingApiResponse = makeBillingApiResponse([
|
||||
{ legend: 'Logs', quantity: [1.23], unit: 'GB' },
|
||||
]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText(/\$0\.3 -/i).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('passes through base tooltipValue when series is not in billingApiResponse', () => {
|
||||
const uPlotInstance = makeUPlotInstance(['Logs', 'Traces']);
|
||||
const billingApiResponse = makeBillingApiResponse([]);
|
||||
|
||||
render(
|
||||
<BillingBarChartTooltip
|
||||
{...baseTooltipArgs}
|
||||
uPlotInstance={uPlotInstance}
|
||||
billingApiResponse={billingApiResponse}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByText('$100.00').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('$50.00').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
import { prepareBillingBarConfig } from '../prepareBillingBarConfig';
|
||||
|
||||
const makeApiResponse = (legends: string[]): MetricRangePayloadProps => ({
|
||||
data: {
|
||||
result: legends.map((legend) => ({
|
||||
legend,
|
||||
queryName: legend,
|
||||
metric: {},
|
||||
values: [[1000, '10']],
|
||||
})),
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
});
|
||||
|
||||
describe('prepareBillingBarConfig', () => {
|
||||
const baseProps = { isDarkMode: false };
|
||||
|
||||
it('returns a builder with no series when apiResponse is undefined', () => {
|
||||
const builder = prepareBillingBarConfig(baseProps);
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a builder with no series when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds one series per result entry with correct labels and colors', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series).toHaveLength(4);
|
||||
expect(config.series?.[1]?.label).toBe('Logs');
|
||||
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
|
||||
expect(config.series?.[2]?.label).toBe('Traces');
|
||||
expect(config.series?.[2]?.stroke).toBe(Color.BG_ROBIN_500);
|
||||
expect(config.series?.[3]?.label).toBe('Metrics');
|
||||
expect(config.series?.[3]?.stroke).toBe(Color.BG_SAKURA_500);
|
||||
});
|
||||
|
||||
it('assigns fallback color (Amber500) for signals beyond the 3-color palette', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['A', 'B', 'C', 'D']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
|
||||
});
|
||||
|
||||
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
|
||||
expect(config.focus).toStrictEqual({ alpha: 0.3 });
|
||||
});
|
||||
|
||||
it('sets no bands when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses queryName as label when legend is undefined', () => {
|
||||
const apiResponse: MetricRangePayloadProps = {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
legend: undefined as any,
|
||||
queryName: 'Logs',
|
||||
metric: {},
|
||||
values: [[1000, '10']],
|
||||
},
|
||||
],
|
||||
resultType: '',
|
||||
newResult: { data: { result: [], resultType: '' } },
|
||||
},
|
||||
};
|
||||
const builder = prepareBillingBarConfig({ isDarkMode: false, apiResponse });
|
||||
const config = builder.getConfig();
|
||||
expect(config.series?.[1]?.label).toBe('Logs');
|
||||
expect(config.series?.[1]?.stroke).toBe(Color.BG_FOREST_300);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
calculateStartEndTime,
|
||||
convertDataToMetricRangePayload,
|
||||
} from '../utils';
|
||||
|
||||
const makeData = (
|
||||
timestamps: number[],
|
||||
billingPeriodStart?: number,
|
||||
billingPeriodEnd?: number,
|
||||
) => ({
|
||||
billingPeriodStart,
|
||||
billingPeriodEnd,
|
||||
details: {
|
||||
total: 0,
|
||||
baseFee: 0,
|
||||
billTotal: 0,
|
||||
breakdown: [
|
||||
{
|
||||
type: 'Logs',
|
||||
unit: 'GB',
|
||||
dayWiseBreakdown: {
|
||||
breakdown: timestamps.map((timestamp) => ({
|
||||
timestamp,
|
||||
total: 0,
|
||||
quantity: 0,
|
||||
count: 0,
|
||||
size: 0,
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe('convertDataToMetricRangePayload', () => {
|
||||
it('returns empty result when all dayWiseBreakdown.breakdown are null', () => {
|
||||
const data = {
|
||||
billingPeriodStart: 1778763678,
|
||||
billingPeriodEnd: 1781442078,
|
||||
details: {
|
||||
total: 0,
|
||||
baseFee: 49,
|
||||
billTotal: 49,
|
||||
breakdown: [
|
||||
{
|
||||
type: 'Metrics',
|
||||
unit: 'Million',
|
||||
tiers: [],
|
||||
dayWiseBreakdown: { type: '', breakdown: null },
|
||||
},
|
||||
{
|
||||
type: 'Traces',
|
||||
unit: 'GB',
|
||||
tiers: [],
|
||||
dayWiseBreakdown: { type: '', breakdown: null },
|
||||
},
|
||||
{
|
||||
type: 'Logs',
|
||||
unit: 'GB',
|
||||
tiers: [],
|
||||
dayWiseBreakdown: { type: '', breakdown: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = convertDataToMetricRangePayload(data);
|
||||
expect(result.data.result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('includes only series that have day-wise data', () => {
|
||||
const data = {
|
||||
details: {
|
||||
breakdown: [
|
||||
{
|
||||
type: 'Metrics',
|
||||
unit: 'Million',
|
||||
dayWiseBreakdown: { breakdown: null },
|
||||
},
|
||||
{
|
||||
type: 'Logs',
|
||||
unit: 'GB',
|
||||
dayWiseBreakdown: {
|
||||
breakdown: [
|
||||
{ timestamp: 1000, total: 5, quantity: 10, count: 0, size: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = convertDataToMetricRangePayload(data);
|
||||
expect(result.data.result).toHaveLength(1);
|
||||
expect(result.data.result[0].legend).toBe('Logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateStartEndTime', () => {
|
||||
it('returns min/max of all breakdown timestamps', () => {
|
||||
const data = makeData([1000, 3000, 2000]);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('includes billingPeriodStart and billingPeriodEnd in the range', () => {
|
||||
const data = makeData([2000, 3000], 500, 4000);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 500,
|
||||
endTime: 4000,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when there are no timestamps and no billing period', () => {
|
||||
expect(calculateStartEndTime({})).toStrictEqual({
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when breakdown is empty', () => {
|
||||
const data = makeData([]);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: undefined,
|
||||
endTime: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out non-finite billingPeriod values', () => {
|
||||
const data = makeData([1000], NaN, Infinity);
|
||||
expect(calculateStartEndTime(data)).toStrictEqual({
|
||||
startTime: 1000,
|
||||
endTime: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('works when details is missing', () => {
|
||||
expect(
|
||||
calculateStartEndTime({ billingPeriodStart: 100, billingPeriodEnd: 200 }),
|
||||
).toStrictEqual({
|
||||
startTime: 100,
|
||||
endTime: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
const BILLING_SERIES_COLORS = [
|
||||
Color.BG_FOREST_300,
|
||||
Color.BG_ROBIN_500,
|
||||
Color.BG_SAKURA_500,
|
||||
];
|
||||
|
||||
export interface PrepareBillingBarConfigProps {
|
||||
isDarkMode: boolean;
|
||||
timezone?: Timezone;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
}
|
||||
|
||||
export function prepareBillingBarConfig({
|
||||
isDarkMode,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
apiResponse,
|
||||
}: PrepareBillingBarConfigProps): UPlotConfigBuilder {
|
||||
const builder = buildBaseConfig({
|
||||
id: 'billing-usage-breakdown',
|
||||
isDarkMode,
|
||||
timezone,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
});
|
||||
|
||||
const results = apiResponse?.data?.result;
|
||||
if (!results?.length) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
const labels = results.map((s) => s.legend || s.queryName || '');
|
||||
|
||||
const colorMapping = labels.reduce<Record<string, string>>(
|
||||
(acc, label, index) => {
|
||||
acc[label] = BILLING_SERIES_COLORS[index] ?? Color.BG_AMBER_500;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
labels.forEach((label) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label,
|
||||
colorMapping,
|
||||
isDarkMode,
|
||||
metric: {},
|
||||
});
|
||||
});
|
||||
|
||||
builder.setBands(getInitialStackedBands(results.length));
|
||||
builder.setPadding([32, 32, 16, 16]);
|
||||
builder.setFocus({ alpha: 0.3 });
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UsageResponsePayloadProps } from 'api/billing/getUsage';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { isEmpty, isNull } from 'lodash-es';
|
||||
import { unparse } from 'papaparse';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
@@ -29,23 +29,25 @@ export const convertDataToMetricRangePayload = (
|
||||
return emptyStateData;
|
||||
}
|
||||
|
||||
const payload = breakdown.map((info: any) => {
|
||||
const metric = info.type;
|
||||
const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort(
|
||||
(a: any, b: any) => a.timestamp - b.timestamp,
|
||||
);
|
||||
const values = (sortedBreakdownData || []).map((categoryInfo: any) => [
|
||||
categoryInfo.timestamp,
|
||||
categoryInfo.total,
|
||||
]);
|
||||
const queryName = info.type;
|
||||
const legend = info.type;
|
||||
const { unit } = info;
|
||||
const quantity = sortedBreakdownData.map(
|
||||
(categoryInfo: any) => categoryInfo.quantity,
|
||||
);
|
||||
return { metric, values, queryName, legend, quantity, unit };
|
||||
});
|
||||
const payload = breakdown
|
||||
.map((info: any) => {
|
||||
const metric = info.type;
|
||||
const sortedBreakdownData = (info?.dayWiseBreakdown?.breakdown || []).sort(
|
||||
(a: any, b: any) => a.timestamp - b.timestamp,
|
||||
);
|
||||
const values = (sortedBreakdownData || []).map((categoryInfo: any) => [
|
||||
categoryInfo.timestamp,
|
||||
categoryInfo.total,
|
||||
]);
|
||||
const queryName = info.type;
|
||||
const legend = info.type;
|
||||
const { unit } = info;
|
||||
const quantity = sortedBreakdownData.map(
|
||||
(categoryInfo: any) => categoryInfo.quantity,
|
||||
);
|
||||
return { metric, values, queryName, legend, quantity, unit };
|
||||
})
|
||||
.filter((series: any) => series.values.length > 0);
|
||||
|
||||
const sortedData = payload.sort((a: any, b: any) => {
|
||||
const sumA = a.values.reduce((acc: any, val: any) => acc + val[1], 0);
|
||||
@@ -120,11 +122,40 @@ export function prepareCsvData(data: Partial<UsageResponsePayloadProps>): {
|
||||
fileName: string;
|
||||
} {
|
||||
const graphCompatibleData = convertDataToMetricRangePayload(data);
|
||||
const chartData = getUPlotChartData(graphCompatibleData);
|
||||
const quantityMapArr = quantityDataArr(graphCompatibleData, chartData[0]);
|
||||
const chartData = prepareChartData(graphCompatibleData);
|
||||
const quantityMapArr = quantityDataArr(
|
||||
graphCompatibleData,
|
||||
chartData[0] as number[],
|
||||
);
|
||||
|
||||
return {
|
||||
csvData: unparse(generateCsvData(quantityMapArr)),
|
||||
fileName: csvFileName(quantityMapArr),
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateStartEndTime(
|
||||
data: Partial<UsageResponsePayloadProps>,
|
||||
): { startTime: number | undefined; endTime: number | undefined } {
|
||||
const timestamps: number[] = [];
|
||||
data?.details?.breakdown?.forEach((breakdown) => {
|
||||
breakdown?.dayWiseBreakdown?.breakdown?.forEach((entry) => {
|
||||
timestamps.push(entry.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
const billingTime: number[] = [
|
||||
data?.billingPeriodStart,
|
||||
data?.billingPeriodEnd,
|
||||
].filter((t): t is number => typeof t === 'number' && Number.isFinite(t));
|
||||
|
||||
const allTimes = [...timestamps, ...billingTime];
|
||||
if (allTimes.length === 0) {
|
||||
return { startTime: undefined, endTime: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: Math.min(...allTimes),
|
||||
endTime: Math.max(...allTimes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ interface TooltipHeaderProps {
|
||||
showTooltipHeader: boolean;
|
||||
isPinned: boolean;
|
||||
activeItem: TooltipContentItem | null;
|
||||
headerRowClassName?: string;
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
export default function TooltipHeader({
|
||||
@@ -26,6 +28,8 @@ export default function TooltipHeader({
|
||||
showTooltipHeader,
|
||||
isPinned,
|
||||
activeItem,
|
||||
headerRowClassName,
|
||||
dateFormat = DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
|
||||
}: TooltipHeaderProps): JSX.Element {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
@@ -44,12 +48,13 @@ export default function TooltipHeader({
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
|
||||
.format(dateFormat);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
uPlotInstance.cursor.idx,
|
||||
showTooltipHeader,
|
||||
dateFormat,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -58,7 +63,7 @@ export default function TooltipHeader({
|
||||
data-testid="uplot-tooltip-header-container"
|
||||
>
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div className={Styles.headerRow}>
|
||||
<div className={cx(Styles.headerRow, headerRowClassName)}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && (
|
||||
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/class-name-pattern
|
||||
*
|
||||
* Enforces camelCase class names in CSS modules.
|
||||
* With Vite's `localsConvention: 'camelCaseOnly'`, kebab-case is converted but
|
||||
* using camelCase directly avoids confusion.
|
||||
*
|
||||
* BAD:
|
||||
* .my-class { } // converted to myClass, but confusing
|
||||
* .my_class { } // converted to myClass, but confusing
|
||||
* .MyClass { } // PascalCase not conventional
|
||||
*
|
||||
* GOOD:
|
||||
* .myClass { }
|
||||
* .alertHistory { }
|
||||
* .statsCard { }
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/class-name-pattern';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
kebabCase: (className) =>
|
||||
`Class "${className}" uses kebab-case. Use camelCase instead: "${toCamelCase(className)}".`,
|
||||
snakeCase: (className) =>
|
||||
`Class "${className}" uses snake_case. Use camelCase instead: "${toCamelCase(className)}".`,
|
||||
pascalCase: (className) =>
|
||||
`Class "${className}" uses PascalCase. Use camelCase instead: "${className.charAt(0).toLowerCase() + className.slice(1)}".`,
|
||||
});
|
||||
|
||||
function toCamelCase(str) {
|
||||
return str
|
||||
.split(/[-_]/)
|
||||
.map((part, i) =>
|
||||
i === 0
|
||||
? part.toLowerCase()
|
||||
: part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function isKebabCase(str) {
|
||||
return str.includes('-');
|
||||
}
|
||||
|
||||
function isSnakeCase(str) {
|
||||
return str.includes('_');
|
||||
}
|
||||
|
||||
function isPascalCase(str) {
|
||||
return /^[A-Z][a-zA-Z0-9]*$/.test(str);
|
||||
}
|
||||
|
||||
const CLASS_PATTERN = /\.([a-zA-Z_][a-zA-Z0-9_-]*)/g;
|
||||
|
||||
const DEFAULT_THIRD_PARTY_PREFIXES = [
|
||||
'ant-', // Ant Design
|
||||
'rc-', // rc-components (Ant Design internals)
|
||||
'recharts-', // Recharts
|
||||
'uplot-', // uPlot
|
||||
'u-', // uPlot legacy
|
||||
'leaflet-', // Leaflet
|
||||
'monaco-', // Monaco editor
|
||||
'react-resizable', // react-resizable
|
||||
'cm-', // CodeMirror
|
||||
];
|
||||
|
||||
// Bare `:global { ... }` block (no parens) makes all descendants global.
|
||||
// `:global(.foo)` is a per-selector escape — handled separately by globalRanges().
|
||||
function hasBareGlobalAncestor(node) {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
const selector = current.selector;
|
||||
if (selector && /:global(?!\s*\()/.test(selector)) {
|
||||
return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Return list of [start, end) index ranges inside `selector` that fall within a
|
||||
// balanced `:global(...)` argument list. Class matches inside these ranges are
|
||||
// third-party and should be skipped.
|
||||
function globalRanges(selector) {
|
||||
const ranges = [];
|
||||
const re = /:global\s*\(/g;
|
||||
let match;
|
||||
while ((match = re.exec(selector)) !== null) {
|
||||
const argStart = match.index + match[0].length;
|
||||
let depth = 1;
|
||||
let i = argStart;
|
||||
while (i < selector.length && depth > 0) {
|
||||
const ch = selector[i];
|
||||
if (ch === '(') {
|
||||
depth++;
|
||||
} else if (ch === ')') {
|
||||
depth--;
|
||||
}
|
||||
if (depth > 0) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
ranges.push([argStart, i]);
|
||||
re.lastIndex = i;
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function indexInRanges(index, ranges) {
|
||||
for (const [start, end] of ranges) {
|
||||
if (index >= start && index < end) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const rule = (primaryOption, secondaryOptions) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userPrefixes =
|
||||
(secondaryOptions && secondaryOptions.ignoreThirdPartyPrefixes) || [];
|
||||
const allPrefixes = [...DEFAULT_THIRD_PARTY_PREFIXES, ...userPrefixes];
|
||||
|
||||
root.walkRules((ruleNode) => {
|
||||
const selector = ruleNode.selector;
|
||||
|
||||
// Bare `:global { }` block makes all descendants global — skip entirely.
|
||||
if (hasBareGlobalAncestor(ruleNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ranges = globalRanges(selector);
|
||||
|
||||
let match;
|
||||
CLASS_PATTERN.lastIndex = 0;
|
||||
|
||||
while ((match = CLASS_PATTERN.exec(selector)) !== null) {
|
||||
const className = match[1];
|
||||
// Skip classes inside `:global(...)` ranges of this selector.
|
||||
if (indexInRanges(match.index, ranges)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip third-party library classes
|
||||
if (allPrefixes.some((prefix) => className.startsWith(prefix))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isKebabCase(className)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.kebabCase(className),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
} else if (isSnakeCase(className)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.snakeCase(className),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
} else if (isPascalCase(className)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.pascalCase(className),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/no-bare-element-selectors
|
||||
*
|
||||
* Prevents bare element selectors at root level in CSS modules.
|
||||
* Bare elements affect ALL instances of that element within component scope,
|
||||
* often unintentionally.
|
||||
*
|
||||
* BAD:
|
||||
* div { }
|
||||
* span { padding: 4px; }
|
||||
* p { margin: 0; }
|
||||
*
|
||||
* GOOD:
|
||||
* .container { }
|
||||
* .title { }
|
||||
*
|
||||
* ALLOWED (nested under class):
|
||||
* .container {
|
||||
* p { margin: 0; } // Scoped to .container
|
||||
* }
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/no-bare-element-selectors';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
unexpected: (element) =>
|
||||
`Bare element selector "${element}" at root level affects all instances. Use class selector or nest under a class.`,
|
||||
unexpectedInCompound: (element, full) =>
|
||||
`Bare element "${element}" in unscoped selector "${full}" at root level matches every "<${element}>" in the module. Anchor the selector with a class (e.g., ".container ${element}") or use :global() if intentional.`,
|
||||
});
|
||||
|
||||
const ELEMENT_PATTERN = /^[a-z][a-z0-9]*$/i;
|
||||
const EXCLUDED_ELEMENTS = new Set(['html', 'body', 'root']);
|
||||
|
||||
function isPartBareElement(part) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
// Strip pseudo suffixes (`:hover`, `::before`) — element part is what's before
|
||||
const elementOnly = trimmed.split(':')[0];
|
||||
if (!elementOnly) {
|
||||
return false;
|
||||
}
|
||||
// Skip class/id/attribute parts
|
||||
if (/[.#[]/.test(elementOnly)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
ELEMENT_PATTERN.test(elementOnly) &&
|
||||
!EXCLUDED_ELEMENTS.has(elementOnly.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function isBareElement(selector) {
|
||||
const trimmed = selector.trim();
|
||||
// Skip combined selectors
|
||||
if (/[,\s>+~]/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
// Skip pseudo-selectors
|
||||
if (trimmed.includes(':')) {
|
||||
return false;
|
||||
}
|
||||
// Skip class/id/attribute selectors
|
||||
if (/[.#[]/.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
ELEMENT_PATTERN.test(trimmed) && !EXCLUDED_ELEMENTS.has(trimmed.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Split a compound selector on combinators (`>`, `+`, `~`, descendant space)
|
||||
// while keeping each part intact. Returns an array of selector parts.
|
||||
function splitOnCombinators(selector) {
|
||||
return selector
|
||||
.split(/\s*[>+~]\s*|\s+/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isInsideGlobal(selector) {
|
||||
return /:global\s*\(/.test(selector);
|
||||
}
|
||||
|
||||
const KEYFRAME_ATRULES = new Set([
|
||||
'keyframes',
|
||||
'-webkit-keyframes',
|
||||
'-moz-keyframes',
|
||||
'-o-keyframes',
|
||||
]);
|
||||
|
||||
// Rule's effective parent is root if all ancestors above it are atrules
|
||||
// (e.g. `@media`, `@supports`). A bare `div` inside `@media { }` at top level
|
||||
// still matches every `<div>` in the module. Exclude `@keyframes` — its
|
||||
// `from`/`to`/`0%` children are not element selectors.
|
||||
function isEffectivelyTopLevel(node) {
|
||||
let parent = node.parent;
|
||||
while (parent && parent.type === 'atrule') {
|
||||
if (KEYFRAME_ATRULES.has(parent.name.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return Boolean(parent) && parent.type === 'root';
|
||||
}
|
||||
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.walkRules((ruleNode) => {
|
||||
if (!isEffectivelyTopLevel(ruleNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectors = ruleNode.selector.split(',').map((s) => s.trim());
|
||||
|
||||
for (const selector of selectors) {
|
||||
if (isInsideGlobal(selector)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBareElement(selector)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.unexpected(selector),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check combined selectors part-by-part. Only flag when the compound
|
||||
// has NO class/id anchor — `.container > div` is already scoped, but
|
||||
// `div + span` at root affects every matching descendant in the module.
|
||||
if (/[\s>+~]/.test(selector) && !/[.#]/.test(selector)) {
|
||||
const parts = splitOnCombinators(selector);
|
||||
for (const part of parts) {
|
||||
if (isPartBareElement(part)) {
|
||||
stylelint.utils.report({
|
||||
message: messages.unexpectedInCompound(part.split(':')[0], selector),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/no-deep-nesting
|
||||
*
|
||||
* Prevents deep nesting in CSS modules (max 3 levels).
|
||||
* Deep nesting creates specificity wars and hard-to-override styles.
|
||||
*
|
||||
* BAD:
|
||||
* .container { .wrapper { .inner { .content { } } } } // 4 levels
|
||||
*
|
||||
* GOOD:
|
||||
* .container { }
|
||||
* .containerWrapper { }
|
||||
* .containerContent { }
|
||||
*
|
||||
* Allowed nesting (pseudo-classes/elements):
|
||||
* .button { &:hover { } &::before { } }
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/no-deep-nesting';
|
||||
const DEFAULT_MAX_DEPTH = 3;
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
tooDeep: (depth, max) =>
|
||||
`Nesting depth ${depth} exceeds maximum of ${max}. Flatten selectors for CSS modules.`,
|
||||
});
|
||||
|
||||
function isPseudoSelector(selector) {
|
||||
return /^&?:/.test(selector.trim());
|
||||
}
|
||||
|
||||
function isParentReference(selector) {
|
||||
return /^&[.#[]/.test(selector.trim());
|
||||
}
|
||||
|
||||
function countNestingDepth(rule, depth = 0) {
|
||||
const selector = rule.selector || '';
|
||||
|
||||
// Don't count pseudo-selectors or parent references toward depth
|
||||
if (isPseudoSelector(selector) || isParentReference(selector)) {
|
||||
// Still check children
|
||||
let maxChildDepth = depth;
|
||||
rule.walkRules?.((child) => {
|
||||
const childDepth = countNestingDepth(child, depth);
|
||||
maxChildDepth = Math.max(maxChildDepth, childDepth);
|
||||
});
|
||||
return maxChildDepth;
|
||||
}
|
||||
|
||||
const currentDepth = depth + 1;
|
||||
let maxDepth = currentDepth;
|
||||
|
||||
rule.walkRules?.((child) => {
|
||||
const childDepth = countNestingDepth(child, currentDepth);
|
||||
maxDepth = Math.max(maxDepth, childDepth);
|
||||
});
|
||||
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
const rule = (primaryOption, secondaryOptions) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxDepth =
|
||||
secondaryOptions && Number.isInteger(secondaryOptions.maxDepth)
|
||||
? secondaryOptions.maxDepth
|
||||
: DEFAULT_MAX_DEPTH;
|
||||
|
||||
root.walkRules((ruleNode) => {
|
||||
// Only check top-level rules
|
||||
if (ruleNode.parent.type !== 'root' && ruleNode.parent.type !== 'atrule') {
|
||||
return;
|
||||
}
|
||||
|
||||
const depth = countNestingDepth(ruleNode);
|
||||
|
||||
if (depth > maxDepth) {
|
||||
stylelint.utils.report({
|
||||
message: messages.tooDeep(depth, maxDepth),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/no-id-selectors
|
||||
*
|
||||
* Prevents ID selectors in CSS modules.
|
||||
* IDs create high specificity, can't be reused, and defeat CSS modules scoping purpose.
|
||||
*
|
||||
* BAD:
|
||||
* #myComponent { }
|
||||
* .container #header { }
|
||||
*
|
||||
* GOOD:
|
||||
* .myComponent { }
|
||||
* .containerHeader { }
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/no-id-selectors';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
unexpected: (selector) =>
|
||||
`ID selector "${selector}" not allowed in CSS modules. Use class selector instead.`,
|
||||
});
|
||||
|
||||
const ID_PATTERN = /#[a-zA-Z_][a-zA-Z0-9_-]*/g;
|
||||
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.walkRules((ruleNode) => {
|
||||
const selector = ruleNode.selector;
|
||||
const matches = selector.match(ID_PATTERN);
|
||||
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
stylelint.utils.report({
|
||||
message: messages.unexpected(match),
|
||||
node: ruleNode,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* Stylelint rule: local/prefer-css-variables
|
||||
*
|
||||
* Warns on hardcoded colors in CSS modules.
|
||||
* Use CSS variables for consistent theming.
|
||||
*
|
||||
* BAD:
|
||||
* color: #ff0000;
|
||||
* background: rgb(255, 0, 0);
|
||||
* border: 1px solid blue;
|
||||
*
|
||||
* GOOD:
|
||||
* color: var(--l1-foreground);
|
||||
* background: var(--primary-background);
|
||||
* border: 1px solid var(--l2-border);
|
||||
*
|
||||
* ALLOWED:
|
||||
* transparent, inherit, currentColor, none
|
||||
* Colors inside var() fallbacks
|
||||
*/
|
||||
import stylelint from 'stylelint';
|
||||
|
||||
const ruleName = 'local/prefer-css-variables';
|
||||
|
||||
const messages = stylelint.utils.ruleMessages(ruleName, {
|
||||
hardcodedColor: (value, property) =>
|
||||
`Hardcoded color "${value}" in "${property}". Use a semantic CSS variable instead (e.g., var(--l1-foreground), var(--primary-background)). See docs/css-modules-guide.md.`,
|
||||
});
|
||||
|
||||
const COLOR_PROPERTIES = new Set([
|
||||
'color',
|
||||
'background',
|
||||
'background-color',
|
||||
'background-image',
|
||||
'border',
|
||||
'border-color',
|
||||
'border-top',
|
||||
'border-right',
|
||||
'border-bottom',
|
||||
'border-left',
|
||||
'border-top-color',
|
||||
'border-right-color',
|
||||
'border-bottom-color',
|
||||
'border-left-color',
|
||||
'border-image',
|
||||
'border-image-source',
|
||||
'outline',
|
||||
'outline-color',
|
||||
'box-shadow',
|
||||
'text-shadow',
|
||||
'fill',
|
||||
'stroke',
|
||||
'caret-color',
|
||||
'text-decoration-color',
|
||||
'column-rule-color',
|
||||
'mask',
|
||||
'mask-image',
|
||||
]);
|
||||
|
||||
const ALLOWED_VALUES = new Set([
|
||||
'transparent',
|
||||
'inherit',
|
||||
'initial',
|
||||
'unset',
|
||||
'currentcolor',
|
||||
'none',
|
||||
'auto',
|
||||
]);
|
||||
|
||||
const HEX_PATTERN = /#[0-9a-fA-F]{3,8}\b/;
|
||||
const RGB_PATTERN = /rgba?\s*\([^)]+\)/i;
|
||||
const HSL_PATTERN = /hsla?\s*\([^)]+\)/i;
|
||||
const NAMED_COLOR_PATTERN =
|
||||
/\b(red|blue|green|yellow|orange|purple|pink|black|white|gray|grey|cyan|magenta|brown|navy|teal|olive|maroon|lime|aqua|fuchsia|silver)\b/i;
|
||||
|
||||
// Strip balanced `fn(...)` sections (e.g. `var(...)`, `url(...)`) from value.
|
||||
// Handles nested parens by counting depth.
|
||||
function stripBalancedFn(value, fnName) {
|
||||
const needle = `${fnName}(`;
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < value.length) {
|
||||
if (value.startsWith(needle, i)) {
|
||||
let depth = 1;
|
||||
i += needle.length;
|
||||
while (i < value.length && depth > 0) {
|
||||
const ch = value[i];
|
||||
if (ch === '(') {
|
||||
depth++;
|
||||
} else if (ch === ')') {
|
||||
depth--;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
out += value[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function containsHardcodedColor(value) {
|
||||
// Strip url(...) first — paths/fragments may contain hex-like or color-named substrings
|
||||
let scanned = value.includes('url(') ? stripBalancedFn(value, 'url') : value;
|
||||
|
||||
// Strip var(...) — color tokens inside var fallbacks are allowed
|
||||
if (scanned.includes('var(')) {
|
||||
scanned = stripBalancedFn(scanned, 'var');
|
||||
if (!scanned.trim()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const lower = scanned.toLowerCase();
|
||||
if (ALLOWED_VALUES.has(lower)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (HEX_PATTERN.test(scanned)) {
|
||||
return scanned.match(HEX_PATTERN)[0];
|
||||
}
|
||||
if (RGB_PATTERN.test(scanned)) {
|
||||
return scanned.match(RGB_PATTERN)[0];
|
||||
}
|
||||
if (HSL_PATTERN.test(scanned)) {
|
||||
return scanned.match(HSL_PATTERN)[0];
|
||||
}
|
||||
if (NAMED_COLOR_PATTERN.test(scanned)) {
|
||||
return scanned.match(NAMED_COLOR_PATTERN)[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const rule = (primaryOption) => {
|
||||
return (root, result) => {
|
||||
if (!primaryOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.walkDecls((decl) => {
|
||||
const prop = decl.prop.toLowerCase();
|
||||
|
||||
if (!COLOR_PROPERTIES.has(prop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hardcodedColor = containsHardcodedColor(decl.value);
|
||||
|
||||
if (hardcodedColor) {
|
||||
stylelint.utils.report({
|
||||
message: messages.hardcodedColor(hardcodedColor, decl.prop),
|
||||
node: decl,
|
||||
result,
|
||||
ruleName,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
rule.ruleName = ruleName;
|
||||
rule.messages = messages;
|
||||
rule.meta = {};
|
||||
|
||||
export { ruleName, rule };
|
||||
export default { ruleName, rule };
|
||||
Reference in New Issue
Block a user