mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 22:50:48 +01:00
Compare commits
121 Commits
test/explo
...
feat/geo-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55d2afaab2 | ||
|
|
6966d30981 | ||
|
|
a0192fa740 | ||
|
|
940b49385f | ||
|
|
cb2b45ce6e | ||
|
|
0a63c42a2f | ||
|
|
a3c5dbde7a | ||
|
|
5892afe538 | ||
|
|
6be0608e08 | ||
|
|
5c6b38c364 | ||
|
|
112a0a5c9f | ||
|
|
87b33bf9cc | ||
|
|
c2c97067be | ||
|
|
d09517b66c | ||
|
|
413c08d965 | ||
|
|
942716d773 | ||
|
|
3acf5221af | ||
|
|
2f350f5f19 | ||
|
|
b49209e811 | ||
|
|
17b740015a | ||
|
|
38d1b69fd4 | ||
|
|
cd3ae16360 | ||
|
|
a82c241ea2 | ||
|
|
9b40cf8b5c | ||
|
|
e9235a10fd | ||
|
|
68476e25f7 | ||
|
|
5ba7a4c86c | ||
|
|
b7b3e48a5d | ||
|
|
ab4bb5a6a5 | ||
|
|
ab5d035022 | ||
|
|
87ec6e9cf8 | ||
|
|
8d93e46b2a | ||
|
|
af55cc43cc | ||
|
|
11d91eb9eb | ||
|
|
917e5da2b7 | ||
|
|
de3d950716 | ||
|
|
bdf7e1708b | ||
|
|
76489f4b43 | ||
|
|
6bf9153612 | ||
|
|
2962c900cc | ||
|
|
b1bcca1332 | ||
|
|
e3f53736ed | ||
|
|
7b05bc3ed4 | ||
|
|
38fe40bbec | ||
|
|
d4fb1bfcfc | ||
|
|
f56517abd4 | ||
|
|
135097d609 | ||
|
|
230d24416f | ||
|
|
652e765f88 | ||
|
|
7ee496c537 | ||
|
|
e60d42799e | ||
|
|
2ec0e0d9ca | ||
|
|
90825d7794 | ||
|
|
2dbf2982e5 | ||
|
|
4ffb8ee309 | ||
|
|
6df6399de6 | ||
|
|
64009bddd2 | ||
|
|
ae8b337a7a | ||
|
|
c4829af7da | ||
|
|
f97d362587 | ||
|
|
6c64cb83c6 | ||
|
|
1f82c9822b | ||
|
|
cf08a5f54c | ||
|
|
e29c879cf7 | ||
|
|
ef096b537e | ||
|
|
1a9153724c | ||
|
|
010b850bb9 | ||
|
|
3eaddaca62 | ||
|
|
7ea347985c | ||
|
|
1669fdec4b | ||
|
|
dd6a951751 | ||
|
|
5c00cfb92e | ||
|
|
e84c5c2a9e | ||
|
|
a7f92f9e87 | ||
|
|
2b411943ab | ||
|
|
1e21c16d1d | ||
|
|
b15f1e2d0a | ||
|
|
28afe94a79 | ||
|
|
fd728d75a9 | ||
|
|
9e28082b99 | ||
|
|
3e267ad40f | ||
|
|
78b3d4c233 | ||
|
|
6f529e7864 | ||
|
|
6d91bc2df1 | ||
|
|
800dda2846 | ||
|
|
a8fb335f2e | ||
|
|
c235957201 | ||
|
|
40314259dd | ||
|
|
bf61ab1b65 | ||
|
|
a143e89fff | ||
|
|
639e091892 | ||
|
|
2bb9496b35 | ||
|
|
251855873d | ||
|
|
69d94733ce | ||
|
|
a83b78d401 | ||
|
|
214d8e4d58 | ||
|
|
74f1a1be42 | ||
|
|
658b2a654b | ||
|
|
088d55cca0 | ||
|
|
d28cd7cf2e | ||
|
|
268c67d34b | ||
|
|
206349b75c | ||
|
|
11ae922ff7 | ||
|
|
3e087fe7bd | ||
|
|
9ec39f2494 | ||
|
|
ef67a47cf3 | ||
|
|
441f7e2927 | ||
|
|
4aff26f696 | ||
|
|
47a773245b | ||
|
|
375b3f8305 | ||
|
|
aaec7c89c5 | ||
|
|
57c193ec8f | ||
|
|
da556abac1 | ||
|
|
cc8440248b | ||
|
|
e84ddfb61e | ||
|
|
dc9fda2916 | ||
|
|
670677bae2 | ||
|
|
e4d66c5413 | ||
|
|
1371cb0beb | ||
|
|
140498cb11 | ||
|
|
ba01bc0418 |
@@ -17,3 +17,4 @@ For the Python integration suite under `tests/`. Setup, running, and suite layou
|
||||
- **snake_case parametrize ids.** `ids=["fill_gaps", "fill_zero"]`, not camelCase.
|
||||
- **Name suite files with the two-digit prefix (`NN_*.py`).** `pyproject.toml` restricts collection to `[0-9][0-9]_*.py` (plus the bootstrap `setup.py` / `run.py`) — a file that doesn't match is silently never collected.
|
||||
- **Always run pytest from `tests/`.** `--import-mode=importlib` is what allows same-basename files across suites (`querier/01_logs.py` vs `rawexportdata/01_logs.py`), but it disables pytest's implicit `sys.path` injection — `import fixtures` only resolves via `pythonpath = ["."]` from that rootdir.
|
||||
- **After changing any `.py` file, run `make py-fmt` and `make py-lint`** from the repo root and fix what they report.
|
||||
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1,3 +1,4 @@
|
||||
*.css linguist-detectable=false
|
||||
*.stories.mocks.tsx linguist-generated=true
|
||||
**/__story_mockdata__/** linguist-generated=true
|
||||
tests/e2e/drivers/** linguist-generated=true
|
||||
|
||||
47
.github/workflows/e2eci.yaml
vendored
47
.github/workflows/e2eci.yaml
vendored
@@ -3,16 +3,44 @@ name: e2eci
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
concurrency:
|
||||
group: e2eci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
if: github.event_name != 'merge_group'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
e2e: ${{ steps.changes.outputs.e2e }}
|
||||
steps:
|
||||
- name: changes
|
||||
id: changes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
LABELED: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
|
||||
run: |
|
||||
files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename')
|
||||
if [ "$LABELED" = true ] || grep -qE '^(frontend|tests/e2e)/' <<< "$files"; then
|
||||
echo "e2e=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fmtlint:
|
||||
needs: changes
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
@@ -41,9 +69,10 @@ jobs:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4]
|
||||
needs: changes
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -152,3 +181,17 @@ jobs:
|
||||
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
|
||||
path: tests/e2e/artifacts/
|
||||
retention-days: 5
|
||||
# Required status check. Fails when e2e was needed but did not pass,
|
||||
# including fork and dependabot PRs still waiting on safe-to-test.
|
||||
e2e:
|
||||
if: always()
|
||||
needs: [changes, fmtlint, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: verdict
|
||||
if: |
|
||||
github.event_name != 'merge_group' && (needs.changes.result != 'success' ||
|
||||
(needs.changes.outputs.e2e == 'true' && (needs.fmtlint.result != 'success' || needs.test.result != 'success')))
|
||||
run: |
|
||||
echo "::error::e2e did not pass. Fork and dependabot PRs need the safe-to-test label."
|
||||
exit 1
|
||||
|
||||
@@ -1,5 +1,219 @@
|
||||
components:
|
||||
schemas:
|
||||
AiobservabilitytypesBlobPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesChatMessage:
|
||||
properties:
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
parts:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesParts'
|
||||
role:
|
||||
type: string
|
||||
required:
|
||||
- role
|
||||
- parts
|
||||
type: object
|
||||
AiobservabilitytypesCompactionPart:
|
||||
properties:
|
||||
content:
|
||||
nullable: true
|
||||
type: string
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
type: object
|
||||
AiobservabilitytypesFilePart:
|
||||
properties:
|
||||
file_id:
|
||||
type: string
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- file_id
|
||||
type: object
|
||||
AiobservabilitytypesGenericPart:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesGenericServerToolCall:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesGenericServerToolCallResponse:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
AiobservabilitytypesInputMessages:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesChatMessage'
|
||||
type: array
|
||||
AiobservabilitytypesOutputMessage:
|
||||
properties:
|
||||
finish_reason:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
nullable: true
|
||||
type: string
|
||||
parts:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesParts'
|
||||
role:
|
||||
type: string
|
||||
required:
|
||||
- role
|
||||
- parts
|
||||
type: object
|
||||
AiobservabilitytypesOutputMessages:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessage'
|
||||
type: array
|
||||
AiobservabilitytypesPart:
|
||||
discriminator:
|
||||
mapping:
|
||||
blob: '#/components/schemas/AiobservabilitytypesBlobPart'
|
||||
compaction: '#/components/schemas/AiobservabilitytypesCompactionPart'
|
||||
file: '#/components/schemas/AiobservabilitytypesFilePart'
|
||||
reasoning: '#/components/schemas/AiobservabilitytypesReasoningPart'
|
||||
server_tool_call: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
|
||||
server_tool_call_response: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
|
||||
text: '#/components/schemas/AiobservabilitytypesTextPart'
|
||||
tool_call: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
|
||||
tool_call_response: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
|
||||
uri: '#/components/schemas/AiobservabilitytypesUriPart'
|
||||
propertyName: type
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesTextPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesToolCallRequestPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesToolCallResponsePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesServerToolCallResponsePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesBlobPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesFilePart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesUriPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesReasoningPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesCompactionPart'
|
||||
- $ref: '#/components/schemas/AiobservabilitytypesGenericPart'
|
||||
type: object
|
||||
AiobservabilitytypesParts:
|
||||
items:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesPart'
|
||||
type: array
|
||||
AiobservabilitytypesReasoningPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesServerToolCallPart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
server_tool_call:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCall'
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
- server_tool_call
|
||||
type: object
|
||||
AiobservabilitytypesServerToolCallResponsePart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
server_tool_call_response:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesGenericServerToolCallResponse'
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- server_tool_call_response
|
||||
type: object
|
||||
AiobservabilitytypesTextPart:
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- content
|
||||
type: object
|
||||
AiobservabilitytypesToolCallRequestPart:
|
||||
properties:
|
||||
arguments: {}
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- name
|
||||
type: object
|
||||
AiobservabilitytypesToolCallResponsePart:
|
||||
properties:
|
||||
id:
|
||||
nullable: true
|
||||
type: string
|
||||
response:
|
||||
nullable: true
|
||||
type:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- response
|
||||
type: object
|
||||
AiobservabilitytypesUriPart:
|
||||
properties:
|
||||
mime_type:
|
||||
nullable: true
|
||||
type: string
|
||||
modality:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
required:
|
||||
- type
|
||||
- modality
|
||||
- uri
|
||||
type: object
|
||||
AlertmanagertypesChannel:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -3367,6 +3581,69 @@ components:
|
||||
repeatVariable:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesAIBuilderQuerySpec:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
functions:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Function'
|
||||
nullable: true
|
||||
type: array
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
nullable: true
|
||||
type: array
|
||||
having:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Having'
|
||||
legend:
|
||||
type: string
|
||||
limit:
|
||||
type: integer
|
||||
limitBy:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LimitBy'
|
||||
name:
|
||||
type: string
|
||||
offset:
|
||||
type: integer
|
||||
order:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
|
||||
nullable: true
|
||||
type: array
|
||||
secondaryAggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5SecondaryAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
selectFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
enum:
|
||||
- traces
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/components/schemas/TelemetrytypesSource'
|
||||
stepInterval:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Step'
|
||||
required:
|
||||
- signal
|
||||
type: object
|
||||
DashboardtypesAreaChartAppearance:
|
||||
properties:
|
||||
fillMode:
|
||||
@@ -3425,6 +3702,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -3455,6 +3739,11 @@ components:
|
||||
timePreference:
|
||||
$ref: '#/components/schemas/DashboardtypesTimePreference'
|
||||
type: object
|
||||
DashboardtypesBubbleRadius:
|
||||
maximum: 64
|
||||
minimum: 1
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesBuilderQuerySpec:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -3627,6 +3916,149 @@ components:
|
||||
minimum: 0
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesGeoMapBubbles:
|
||||
properties:
|
||||
maxRadius:
|
||||
$ref: '#/components/schemas/DashboardtypesBubbleRadius'
|
||||
minRadius:
|
||||
$ref: '#/components/schemas/DashboardtypesBubbleRadius'
|
||||
type: object
|
||||
DashboardtypesGeoMapChartAppearance:
|
||||
properties:
|
||||
bubbles:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapBubbles'
|
||||
colors:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapColors'
|
||||
labels:
|
||||
description: Shows country and state names once each fits inside its shape.
|
||||
nullable: true
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesGeoMapColors:
|
||||
properties:
|
||||
fill:
|
||||
type: string
|
||||
max:
|
||||
nullable: true
|
||||
type: number
|
||||
min:
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
palette:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
steps:
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesGeoMapCountryFormat:
|
||||
enum:
|
||||
- auto
|
||||
- alpha2
|
||||
- alpha3
|
||||
- name
|
||||
type: string
|
||||
DashboardtypesGeoMapLayer:
|
||||
enum:
|
||||
- regions
|
||||
- bubbles
|
||||
- points
|
||||
type: string
|
||||
DashboardtypesGeoMapLocation:
|
||||
properties:
|
||||
country:
|
||||
description: Group-by key holding the country, for the country and subdivision
|
||||
modes.
|
||||
type: string
|
||||
countryFormat:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapCountryFormat'
|
||||
lat:
|
||||
description: Group-by key holding the latitude, for the coordinates mode.
|
||||
type: string
|
||||
lon:
|
||||
description: Group-by key holding the longitude, for the coordinates mode.
|
||||
type: string
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapLocationMode'
|
||||
region:
|
||||
description: Group-by key holding the ISO 3166-2 region code (e.g. geo.region.iso_code),
|
||||
for the subdivision mode.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesGeoMapLocationMode:
|
||||
enum:
|
||||
- auto
|
||||
- country
|
||||
- subdivision
|
||||
- coordinates
|
||||
type: string
|
||||
DashboardtypesGeoMapPanelSpec:
|
||||
properties:
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
location:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapLocation'
|
||||
map:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapSettings'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesGeoMapSettings:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Value column key that sets the colour. Empty uses the first
|
||||
value column.
|
||||
type: string
|
||||
layer:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapLayer'
|
||||
sizeBy:
|
||||
description: Value column key that sets the bubble or point size. Empty
|
||||
uses colorBy.
|
||||
type: string
|
||||
view:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapView'
|
||||
viewCountry:
|
||||
description: ISO 3166-1 alpha-2 code of the country the country view shows.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesGeoMapThreshold:
|
||||
properties:
|
||||
color:
|
||||
type: string
|
||||
operator:
|
||||
$ref: '#/components/schemas/DashboardtypesComparisonOperator'
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesGeoMapView:
|
||||
enum:
|
||||
- world
|
||||
- fit
|
||||
- africa
|
||||
- asia
|
||||
- europe
|
||||
- north_america
|
||||
- south_america
|
||||
- oceania
|
||||
- country
|
||||
type: string
|
||||
DashboardtypesGettableDashboardV2:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -4162,14 +4594,17 @@ components:
|
||||
mapping:
|
||||
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/GeoMapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
signoz/TopListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -4182,6 +4617,9 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4195,6 +4633,9 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
- signoz/TopListPanel
|
||||
- signoz/GeoMapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4220,6 +4661,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesGeoMapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/GeoMapPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesGeoMapPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4280,6 +4733,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4316,6 +4781,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/TopListPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTopListPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelSpec:
|
||||
properties:
|
||||
display:
|
||||
@@ -4359,6 +4836,16 @@ components:
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -4420,6 +4907,7 @@ components:
|
||||
DashboardtypesQueryPlugin:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/AIBuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
signoz/BuilderQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
signoz/ClickHouseSQL: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
|
||||
signoz/CompositeQuery: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
@@ -4429,6 +4917,7 @@ components:
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
|
||||
- $ref: '#/components/schemas/DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
|
||||
@@ -4438,12 +4927,25 @@ components:
|
||||
DashboardtypesQueryPluginKind:
|
||||
enum:
|
||||
- signoz/BuilderQuery
|
||||
- signoz/AIBuilderQuery
|
||||
- signoz/CompositeQuery
|
||||
- signoz/Formula
|
||||
- signoz/PromQLQuery
|
||||
- signoz/ClickHouseSQL
|
||||
- signoz/TraceOperator
|
||||
type: string
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAIBuilderQuerySpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/AIBuilderQuery
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesAIBuilderQuerySpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4532,6 +5034,104 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
sizeBy:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesScatterPlotThreshold:
|
||||
properties:
|
||||
axis:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
|
||||
color:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -4645,6 +5245,11 @@ components:
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdAxis:
|
||||
enum:
|
||||
- x
|
||||
- "y"
|
||||
type: string
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
@@ -4716,6 +5321,35 @@ components:
|
||||
timePreference:
|
||||
$ref: '#/components/schemas/DashboardtypesTimePreference'
|
||||
type: object
|
||||
DashboardtypesTopListAppearance:
|
||||
properties:
|
||||
barColor:
|
||||
description: Hex color of every bar; a matching background threshold overrides
|
||||
it.
|
||||
type: string
|
||||
showRank:
|
||||
description: Numbers each row by its position in the list.
|
||||
type: boolean
|
||||
showShare:
|
||||
description: Shows each row's share of the listed rows' total.
|
||||
type: boolean
|
||||
required:
|
||||
- barColor
|
||||
type: object
|
||||
DashboardtypesTopListPanelSpec:
|
||||
properties:
|
||||
appearance:
|
||||
$ref: '#/components/schemas/DashboardtypesTopListAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesComparisonThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesUpdatableDashboardV2:
|
||||
properties:
|
||||
image:
|
||||
@@ -10225,6 +10859,14 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
formattedInput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
|
||||
formattedOutput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
|
||||
formatterWarnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
has_error:
|
||||
type: boolean
|
||||
kind_string:
|
||||
@@ -10267,6 +10909,9 @@ components:
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
- formattedInput
|
||||
- formattedOutput
|
||||
- formatterWarnings
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
@@ -10347,6 +10992,14 @@ components:
|
||||
flags:
|
||||
minimum: 0
|
||||
type: integer
|
||||
formattedInput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesInputMessages'
|
||||
formattedOutput:
|
||||
$ref: '#/components/schemas/AiobservabilitytypesOutputMessages'
|
||||
formatterWarnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
has_children:
|
||||
type: boolean
|
||||
has_error:
|
||||
@@ -16134,9 +16787,12 @@ paths:
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the spans carrying gen_ai input or output messages in timestamp
|
||||
order. Pass nextCursor as after or prevCursor as before to page, or spanId
|
||||
to open the page around a span.
|
||||
description: Returns the gen_ai spans of a trace, model calls, tool calls and
|
||||
agent spans, in timestamp order. The messages a span carries are decoded into
|
||||
formattedInput and formattedOutput, with formatterWarnings holding what the
|
||||
converter could not resolve; a span without messages has both empty. Pass
|
||||
nextCursor as after or prevCursor as before to page, or spanId to open the
|
||||
page around a span.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- description: Page size, at most 100. 0 means 20.
|
||||
@@ -28280,7 +28936,9 @@ paths:
|
||||
deprecated: false
|
||||
description: Returns the waterfall view of spans including all spans if total
|
||||
spans are under a limit, a max count otherwise. Aggregations are dropped compared
|
||||
to v3
|
||||
to v3. A span carrying gen_ai input or output messages also has them decoded
|
||||
into formattedInput and formattedOutput, with formatterWarnings holding what
|
||||
the converter could not resolve.
|
||||
operationId: GetWaterfallV4
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -20,6 +20,7 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
|
||||
- [Provider](provider.md) - Dependency injection and provider patterns
|
||||
- [Packages](packages.md) - Naming, layout, and conventions for `pkg/` packages
|
||||
- [Service](service.md) - Managed service lifecycle with `factory.Service`
|
||||
- [Span Mapper](spanmapper.md) - Provenance of the shipped span mapper definitions and what is left unmapped
|
||||
- [SQL](sql.md) - Database and SQL patterns
|
||||
- [DSL Filtering to SQL](dslfilteringtosql.md) - Compiling the list filter DSL to relational-store WHERE clauses
|
||||
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
|
||||
|
||||
62
docs/contributing/go/spanmapper.md
Normal file
62
docs/contributing/go/spanmapper.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Span Mapper
|
||||
|
||||
Where each source key in the shipped definitions under `pkg/modules/spanmapper/implspanmapper/fs/definitions/` comes from. A group runs on spans with an attribute key containing one of its condition substrings; sources are tried by priority and the first present wins; an existing target is never overwritten. Bump `version` on every change.
|
||||
|
||||
## gen_ai.llm
|
||||
|
||||
Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` from the response, so a failed call has no model key).
|
||||
|
||||
| Source | Emitted by |
|
||||
|---|---|
|
||||
| `llm.model_name`, `llm.provider`, `llm.system`, `llm.token_count.prompt`, `llm.token_count.completion`, `llm.token_count.prompt_details.cache_read`, `llm.token_count.prompt_details.cache_write`, `llm.token_count.completion_details.reasoning`, `llm.finish_reason`, `input.value`, `output.value`, `embedding.model_name`, `reranker.model_name`, `openinference.span.kind` | OpenInference |
|
||||
| `llm.request.model`, `llm.response.model`, `llm.vendor`, `llm.usage.prompt_tokens`, `llm.usage.completion_tokens`, `llm.response.finish_reason`, `llm.response.stop_reason` | OpenLLMetry before the `gen_ai` rename |
|
||||
| `gen_ai.system`, `gen_ai.usage.prompt_tokens`, `gen_ai.usage.completion_tokens`, `gen_ai.usage.cache_read_input_tokens`, `gen_ai.usage.cache_creation_input_tokens`, `gen_ai.usage.reasoning_tokens`, `gen_ai.prompt`, `gen_ai.completion`, `llm.request.type`, `traceloop.span.kind` | OpenLLMetry, LiteLLM, CrewAI, Mistral, LangSmith, OpenRouter gateway |
|
||||
| `gen_ai.usage.cache_write.input_tokens` | OTel semconv draft spelling, Codex |
|
||||
| `gen_ai.response.finish_reason`, `gen_ai.usage.prompt_token_details.cached_read_tokens`, `gen_ai.usage.completion_token_details.reasoning_tokens` | Bifrost gateway |
|
||||
| `gen_ai.usage.input_tokens.cached` | OpenRouter gateway |
|
||||
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
|
||||
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
|
||||
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
|
||||
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
|
||||
| `session.id` | OpenInference, Langfuse, Claude Code |
|
||||
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
|
||||
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |
|
||||
|
||||
## gen_ai.tool
|
||||
|
||||
Condition: `tool.name` (OpenInference), `ai.toolCall.` (Vercel, Mastra), `tool_call_args`, `tool_response` (Google ADK), `tool_use_id` (Claude Code, opentelemetry-hooks). Not `tool_name`: Codex records it on the model call span.
|
||||
|
||||
| Source | Emitted by |
|
||||
|---|---|
|
||||
| `tool.name`, `tool.id`, `tool.description`, `input.value`, `output.value`, `openinference.span.kind` | OpenInference |
|
||||
| `ai.toolCall.name`, `ai.toolCall.id`, `ai.toolCall.args`, `ai.toolCall.result` | Vercel AI SDK, Mastra |
|
||||
| `traceloop.entity.input`, `traceloop.entity.output`, `traceloop.span.kind`, `llm.request.type` | OpenLLMetry |
|
||||
| `gcp.vertex.agent.tool_call_args`, `gcp.vertex.agent.tool_response` | Google ADK |
|
||||
| `tool_name` | Claude Code `claude_code.tool` spans |
|
||||
| `gen_ai.client.tool_name`, `gen_ai.client.tool_use_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
|
||||
|
||||
## gen_ai.agent
|
||||
|
||||
Condition: `agent`.
|
||||
|
||||
| Source | Emitted by |
|
||||
|---|---|
|
||||
| `agent.name`, `agent.id`, `agent.description`, `openinference.span.kind` | OpenInference; `agent.id` and `agent.description` seen in no capture |
|
||||
| `agent_name`, `final_result` | Pydantic AI |
|
||||
| `crewai.agent.role`, `crewai.agent.id` | CrewAI |
|
||||
| `traceloop.span.kind`, `llm.request.type` | OpenLLMetry |
|
||||
| `agent_id` | Claude Code |
|
||||
|
||||
## Coverage of documented integrations
|
||||
|
||||
Every integration under [signoz.io/docs/llm-observability](https://signoz.io/docs/llm-observability/) was checked by looking at the instrumentation its page installs and the keys that instrumentation writes.
|
||||
|
||||
- Most pages install OpenInference or OpenLLMetry, or an SDK that writes OTel GenAI semconv itself (OpenAI, OpenAI Agents, Pydantic AI, AutoGen, Microsoft Agent Framework, Semantic Kernel, Copilot Chat, LiteLLM, OpenRouter). Their keys are the rows above.
|
||||
- Three write their own keys and have their own rows: Claude Code and Claude Agent SDK, Cursor through opentelemetry-hooks, vLLM (response model only).
|
||||
- Codex cannot be priced by renaming: `model` sits on the sampling function spans, `gen_ai.usage.*` on the child `handle_responses` span, `conversation.id` on the `codex.sampling` phase span, never together on one span.
|
||||
|
||||
To check a new integration, capture a trace and look for a model, usage, tool or messages key that no row above lists.
|
||||
|
||||
## Left unmapped on purpose
|
||||
|
||||
LiteLLM `llm.openai.messages` and `llm.openai.choices` (debug echo of the parent span's messages, Python repr). Total tokens and vendor cost keys (no target; the pricing processor owns cost). `ai.response.msToFirstChunk` and `gen_ai.latency.time_to_first_token` (milliseconds and seconds under one target).
|
||||
@@ -86,6 +86,9 @@ tests/
|
||||
├── .env.local # generated by bootstrap/setup.py (gitignored)
|
||||
├── bootstrap/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── drivers/ # the driver kit and one driver per feature
|
||||
│ ├── kit.ts
|
||||
│ └── routing.ts
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -112,6 +115,24 @@ These two folders look similar but mean different things:
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
### Drivers
|
||||
|
||||
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
|
||||
|
||||
```ts
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
await routing.goto(PATH.metricsSummary);
|
||||
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
### Extended fixtures
|
||||
|
||||
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
|
||||
|
||||
@@ -25,6 +25,7 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- 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.
|
||||
- Before styling a `@signozhq/ui` component, check its props in `node_modules/@signozhq/ui/dist/<component>/*.d.ts` for `className`/`style` support; some omit them. Avoid overriding the component's CSS vars unless explicitly needed.
|
||||
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
|
||||
|
||||
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:
|
||||
|
||||
@@ -57,6 +57,7 @@ const mockPaths = {
|
||||
const mockTzDate = jest.fn(
|
||||
(date: Date, _timezone: string) => new Date(date.getTime()),
|
||||
);
|
||||
const mockOrient = jest.fn();
|
||||
|
||||
// Mock uPlot constructor - this needs to be a proper constructor function
|
||||
function MockUPlot(
|
||||
@@ -70,6 +71,7 @@ function MockUPlot(
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
MockUPlot.orient = mockOrient;
|
||||
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ These hold for every page. The per-pattern sections below only add to them.
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
them, not the surrounding content.
|
||||
8. **Expose the selector.** A role is scoped to a resource by pasting its selector into the role editor, so every
|
||||
resource a role can be scoped to shows that selector with a copy control on its detail view or drawer. The selector
|
||||
is whatever the editor accepts for the resource (dashboard ID, service account ID, role name, ...), listed per
|
||||
resource in [permissions.config.ts](../src/container/RolesSettings/permissions.config.ts). It is already known from
|
||||
the row or URL, so it stays visible and copyable without `read`. Reference: the ID field in
|
||||
[ServiceAccountDrawer/OverviewTab.tsx](../src/components/ServiceAccountDrawer/OverviewTab.tsx).
|
||||
|
||||
## Page patterns
|
||||
|
||||
@@ -136,6 +142,8 @@ Use the existing components. Create a new one only if none fit.
|
||||
- [ ] Am I gating a shared component rather than a page or section?
|
||||
- If so, it must stay functional with no permission. Example: the Query Builder works without field suggestions when
|
||||
the user cannot read them.
|
||||
- [ ] Does the page expose the resource's selector with a copy control (rule 8)?
|
||||
- The expected format is the `selectorPlaceholder` for the resource in `permissions.config.ts`.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|ol|rbush|quickselect|earcut|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|ol|rbush|quickselect|earcut|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -41,6 +41,20 @@ if (!HTMLElement.prototype.releasePointerCapture) {
|
||||
HTMLElement.prototype.releasePointerCapture = function (): void {};
|
||||
}
|
||||
|
||||
// jsdom has no PointerEvent; Base UI Switch constructs one on click.
|
||||
if (typeof window.PointerEvent === 'undefined') {
|
||||
class PointerEventMock extends MouseEvent {
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
constructor(type: string, init: PointerEventInit = {}) {
|
||||
super(type, init);
|
||||
this.pointerId = init.pointerId ?? 0;
|
||||
this.pointerType = init.pointerType ?? '';
|
||||
}
|
||||
}
|
||||
(window as any).PointerEvent = PointerEventMock;
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver === 'undefined') {
|
||||
class IntersectionObserverMock {
|
||||
observe(): void {}
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"test": "jest",
|
||||
"test:changedsince": "jest --changedSince=main --coverage --silent",
|
||||
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh",
|
||||
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'"
|
||||
"generate:config:web-settings": "json2ts ./src/schemas/generated/webSettings.schema.json -o src/types/generated/webSettings.ts --style.useTabs --style.tabWidth=1 --style.singleQuote --bannerComment '/* AUTO GENERATED FILE - DO NOT EDIT - GENERATED FROM frontend/src/schemas/generated/webSettings.schema.json */'",
|
||||
"build:geo-outlines": "node scripts/geo-outlines/build.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
@@ -49,12 +50,13 @@
|
||||
"@dnd-kit/sortable": "8.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@grafana/data": "^11.6.15",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@signozhq/design-tokens": "2.1.6",
|
||||
"@signozhq/design-tokens": "2.2.1",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/ui": "0.1.0",
|
||||
"@signozhq/ui": "0.2.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -95,6 +97,7 @@
|
||||
"monaco-editor": "0.55.1",
|
||||
"motion": "12.4.13",
|
||||
"nuqs": "2.8.8",
|
||||
"ol": "10.11.0",
|
||||
"overlayscrollbars": "^2.16.0",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"papaparse": "5.4.1",
|
||||
@@ -131,6 +134,7 @@
|
||||
"rrule": "2.8.1",
|
||||
"styled-components": "^5.3.11",
|
||||
"timestamp-nano": "^1.0.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"translation-resilience": "^0.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"uplot": "1.6.31",
|
||||
@@ -174,6 +178,7 @@
|
||||
"@types/crypto-js": "4.2.2",
|
||||
"@types/d3-hierarchy": "1.1.11",
|
||||
"@types/event-source-polyfill": "^1.0.0",
|
||||
"@types/geojson": "7946.0.16",
|
||||
"@types/history": "4.7.11",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
@@ -191,8 +196,11 @@
|
||||
"@types/redux-mock-store": "1.0.4",
|
||||
"@types/styled-components": "^5.1.4",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/topojson-specification": "1.0.5",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260430.1",
|
||||
"d3-geo": "3.1.1",
|
||||
"eslint-plugin-sonarjs": "4.0.2",
|
||||
"glob": "^13.0.6",
|
||||
"husky": "^7.0.4",
|
||||
@@ -215,6 +223,8 @@
|
||||
"storybook": "10.5.9",
|
||||
"stylelint": "17.15.0",
|
||||
"svgo": "4.1.0",
|
||||
"topojson-server": "3.0.1",
|
||||
"topojson-simplify": "3.0.3",
|
||||
"ts-jest": "29.4.9",
|
||||
"typescript-plugin-css-modules": "5.2.0",
|
||||
"use-sync-external-store": "1.6.0",
|
||||
|
||||
632
frontend/pnpm-lock.yaml
generated
632
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
208
frontend/scripts/geo-outlines/build.mjs
Normal file
208
frontend/scripts/geo-outlines/build.mjs
Normal file
@@ -0,0 +1,208 @@
|
||||
// Builds the Geo Map outlines assets: Natural Earth 50m admin-0 countries, and
|
||||
// 10m admin-1 states/provinces for every country with at least two of them.
|
||||
// Run by hand when bumping Natural Earth or editing regions/aliases; the output is committed.
|
||||
// Usage: pnpm build:geo-outlines
|
||||
//
|
||||
// Nothing is written until every invariant in validate.mjs holds. The assets
|
||||
// are built in a staging folder and swapped in whole, so a failed run leaves
|
||||
// the committed ones as they were.
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
|
||||
import {
|
||||
ASSETS_DIR,
|
||||
COUNTRIES_URL,
|
||||
SUBDIVISION_LIST_FILE,
|
||||
SUBDIVISIONS_URL,
|
||||
} from './config.mjs';
|
||||
import { buildCountries } from './countries.mjs';
|
||||
import { buildSubdivisions } from './subdivisions.mjs';
|
||||
import { validate } from './validate.mjs';
|
||||
|
||||
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const DOWNLOAD_ATTEMPTS = 3;
|
||||
|
||||
const STAGING_DIR = `${ASSETS_DIR}.staging`;
|
||||
const PREVIOUS_DIR = `${ASSETS_DIR}.previous`;
|
||||
// Everything in the assets folder is generated; anything else would be lost
|
||||
// in the swap, so its presence stops the build.
|
||||
const GENERATED_ENTRIES = new Set([
|
||||
'countries-50m.json',
|
||||
'lookups.json',
|
||||
'subdivisions',
|
||||
]);
|
||||
|
||||
const kb = (bytes) => `${(bytes / 1024).toFixed(0)} KB`;
|
||||
const sizeOf = (json) =>
|
||||
`${kb(Buffer.byteLength(json))} raw / ${kb(gzipSync(json).length)} gzip`;
|
||||
|
||||
async function downloadFeatures(url) {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const body = await response.json();
|
||||
if (
|
||||
body?.type !== 'FeatureCollection' ||
|
||||
!Array.isArray(body.features) ||
|
||||
body.features.length === 0
|
||||
) {
|
||||
throw new Error('response is not a non-empty GeoJSON FeatureCollection');
|
||||
}
|
||||
return body;
|
||||
} catch (error) {
|
||||
if (attempt === DOWNLOAD_ATTEMPTS) {
|
||||
throw new Error(
|
||||
`download failed after ${attempt} attempts: ${url}\n ${error.message}`,
|
||||
);
|
||||
}
|
||||
await sleep(attempt * 2_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function subdivisionListSource(countries) {
|
||||
return `// Generated by scripts/geo-outlines/build.mjs; do not edit.
|
||||
export const SUBDIVISION_COUNTRY_CODES = [
|
||||
${countries.map((country) => `\t'${country}',`).join('\n')}
|
||||
] as const;
|
||||
`;
|
||||
}
|
||||
|
||||
// A run that died mid-swap leaves the assets in the previous folder.
|
||||
function recoverInterruptedSwap() {
|
||||
if (!existsSync(ASSETS_DIR) && existsSync(PREVIOUS_DIR)) {
|
||||
renameSync(PREVIOUS_DIR, ASSETS_DIR);
|
||||
}
|
||||
rmSync(STAGING_DIR, { recursive: true, force: true });
|
||||
rmSync(PREVIOUS_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function assertOnlyGeneratedEntries() {
|
||||
if (!existsSync(ASSETS_DIR)) {
|
||||
return;
|
||||
}
|
||||
const foreign = readdirSync(ASSETS_DIR).filter(
|
||||
(entry) => !GENERATED_ENTRIES.has(entry),
|
||||
);
|
||||
if (foreign.length > 0) {
|
||||
throw new Error(
|
||||
`${ASSETS_DIR} holds files this build doesn't generate: ${foreign.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes every output under the staging folder; returns the sizes report. */
|
||||
function stage(outputs) {
|
||||
const subdivisionDir = join(STAGING_DIR, 'subdivisions');
|
||||
mkdirSync(subdivisionDir, { recursive: true });
|
||||
writeFileSync(join(STAGING_DIR, 'countries-50m.json'), outputs.countriesJson);
|
||||
writeFileSync(join(STAGING_DIR, 'lookups.json'), outputs.lookupsJson);
|
||||
const sizes = [];
|
||||
for (const [country, file] of outputs.subdivisionFiles) {
|
||||
const json = JSON.stringify(file);
|
||||
writeFileSync(join(subdivisionDir, `${country}.json`), json);
|
||||
sizes.push({
|
||||
country,
|
||||
shapes: file.objects.subdivisions.geometries.length,
|
||||
gzip: gzipSync(json).length,
|
||||
});
|
||||
}
|
||||
writeFileSync(`${SUBDIVISION_LIST_FILE}.staging`, outputs.subdivisionList);
|
||||
return sizes;
|
||||
}
|
||||
|
||||
/** Swaps the staged assets in; puts the previous ones back if that fails. */
|
||||
function commitStaged() {
|
||||
const hadAssets = existsSync(ASSETS_DIR);
|
||||
if (hadAssets) {
|
||||
renameSync(ASSETS_DIR, PREVIOUS_DIR);
|
||||
}
|
||||
try {
|
||||
renameSync(STAGING_DIR, ASSETS_DIR);
|
||||
} catch (error) {
|
||||
if (hadAssets) {
|
||||
renameSync(PREVIOUS_DIR, ASSETS_DIR);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
renameSync(`${SUBDIVISION_LIST_FILE}.staging`, SUBDIVISION_LIST_FILE);
|
||||
rmSync(PREVIOUS_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function cleanUpStaging() {
|
||||
rmSync(STAGING_DIR, { recursive: true, force: true });
|
||||
rmSync(`${SUBDIVISION_LIST_FILE}.staging`, { force: true });
|
||||
}
|
||||
|
||||
function report(countries, subdivisions, outputs, sizes) {
|
||||
const totalGzip = sizes.reduce((sum, { gzip }) => sum + gzip, 0);
|
||||
return {
|
||||
countries: {
|
||||
...countries.report,
|
||||
sizes: {
|
||||
'countries-50m.json': sizeOf(outputs.countriesJson),
|
||||
'lookups.json': sizeOf(outputs.lookupsJson),
|
||||
},
|
||||
},
|
||||
subdivisions: {
|
||||
...subdivisions.report,
|
||||
countries: sizes.length,
|
||||
totalGzip: kb(totalGzip),
|
||||
largest: [...sizes]
|
||||
.sort((a, b) => b.gzip - a.gzip)
|
||||
.slice(0, 10)
|
||||
.map(
|
||||
({ country, shapes, gzip }) =>
|
||||
`${country}: ${shapes} shapes, ${kb(gzip)} gzip`,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
recoverInterruptedSwap();
|
||||
assertOnlyGeneratedEntries();
|
||||
|
||||
const [countrySource, subdivisionSource] = await Promise.all([
|
||||
downloadFeatures(COUNTRIES_URL),
|
||||
downloadFeatures(SUBDIVISIONS_URL),
|
||||
]);
|
||||
const countries = buildCountries(countrySource);
|
||||
const subdivisions = buildSubdivisions(subdivisionSource);
|
||||
validate(countries, subdivisions);
|
||||
|
||||
const outputs = {
|
||||
countriesJson: JSON.stringify(countries.topology),
|
||||
lookupsJson: JSON.stringify(countries.lookups),
|
||||
subdivisionFiles: subdivisions.files,
|
||||
subdivisionList: subdivisionListSource([...subdivisions.files.keys()]),
|
||||
};
|
||||
try {
|
||||
const sizes = stage(outputs);
|
||||
commitStaged();
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(report(countries, subdivisions, outputs, sizes), null, 2)}\n`,
|
||||
);
|
||||
} finally {
|
||||
cleanUpStaging();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`Geo outlines build failed: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
176
frontend/scripts/geo-outlines/config.mjs
Normal file
176
frontend/scripts/geo-outlines/config.mjs
Normal file
@@ -0,0 +1,176 @@
|
||||
// Inputs and tuning for the Geo Map outlines build. Tables only; no logic.
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const NATURAL_EARTH_BASE =
|
||||
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson';
|
||||
export const COUNTRIES_URL = `${NATURAL_EARTH_BASE}/ne_50m_admin_0_countries.geojson`;
|
||||
export const SUBDIVISIONS_URL = `${NATURAL_EARTH_BASE}/ne_10m_admin_1_states_provinces.geojson`;
|
||||
|
||||
const GEO_MAP_DIR = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'../../src/lib/visualization/charts/GeoMap',
|
||||
);
|
||||
export const ASSETS_DIR = join(GEO_MAP_DIR, 'assets');
|
||||
export const SUBDIVISION_LIST_FILE = join(
|
||||
GEO_MAP_DIR,
|
||||
'geo/subdivisionCountries.ts',
|
||||
);
|
||||
|
||||
export const COUNTRY_QUANTIZATION = 1e5;
|
||||
// Simplification drops every point whose triangle with its neighbours is
|
||||
// smaller than this, in square degrees. A fixed size, unlike a share of all
|
||||
// points, still leaves a small island a few points of its own.
|
||||
export const COUNTRY_MIN_WEIGHT = 3e-3;
|
||||
// 10m is street-level detail; a country view needs far less.
|
||||
export const SUBDIVISION_QUANTIZATION = 1e4;
|
||||
export const SUBDIVISION_MIN_WEIGHT = 1e-3;
|
||||
// A shape this many times the minimum weight or smaller could lose every polygon.
|
||||
export const PROTECTED_SHAPE_FACTOR = 10;
|
||||
|
||||
// A country view needs at least this many coded states to be worth drawing.
|
||||
export const MIN_SUBDIVISIONS = 2;
|
||||
|
||||
// ISO 3166-2 codes that changed after Natural Earth v5.1.2, or older spellings
|
||||
// still in GeoIP data, mapped to the code the shape carries.
|
||||
export const SUBDIVISION_ALIASES = {
|
||||
IN: {
|
||||
'IN-CG': 'IN-CT',
|
||||
'IN-UK': 'IN-UT',
|
||||
'IN-TS': 'IN-TG',
|
||||
'IN-OD': 'IN-OR',
|
||||
'IN-DD': 'IN-DH',
|
||||
'IN-DN': 'IN-DH',
|
||||
},
|
||||
// Natural Earth carries Metro Manila under its retired code.
|
||||
PH: { 'PH-00': 'PH-MNL' },
|
||||
};
|
||||
|
||||
// Shapes Natural Earth still splits along retired lines, merged into the codes
|
||||
// GeoIP reports now; the retired codes become aliases. Northern Ireland's 26
|
||||
// districts became 11 councils in 2015, each a union of old districts.
|
||||
// Current code → [current name, retired codes].
|
||||
export const SUBDIVISION_MERGES = {
|
||||
GB: {
|
||||
'GB-ANN': ['Antrim and Newtownabbey', ['GB-ANT', 'GB-NTA']],
|
||||
'GB-AND': ['Ards and North Down', ['GB-ARD', 'GB-NDN']],
|
||||
'GB-ABC': [
|
||||
'Armagh City, Banbridge and Craigavon',
|
||||
['GB-ARM', 'GB-BNB', 'GB-CGV'],
|
||||
],
|
||||
'GB-BFS': ['Belfast', ['GB-BFS']],
|
||||
'GB-CCG': [
|
||||
'Causeway Coast and Glens',
|
||||
['GB-BLY', 'GB-CLR', 'GB-LMV', 'GB-MYL'],
|
||||
],
|
||||
'GB-DRS': ['Derry City and Strabane', ['GB-DRY', 'GB-STB']],
|
||||
'GB-FMO': ['Fermanagh and Omagh', ['GB-FER', 'GB-OMH']],
|
||||
'GB-LBC': ['Lisburn and Castlereagh', ['GB-LSB', 'GB-CSR']],
|
||||
'GB-MEA': ['Mid and East Antrim', ['GB-BLA', 'GB-CKF', 'GB-LRN']],
|
||||
'GB-MUL': ['Mid Ulster', ['GB-CKT', 'GB-DGN', 'GB-MFT']],
|
||||
'GB-NMD': ['Newry, Mourne and Down', ['GB-NYM', 'GB-DOW']],
|
||||
},
|
||||
};
|
||||
|
||||
// Natural Earth puts these islands in "Seven seas (open ocean)".
|
||||
export const OPEN_OCEAN_REGION = {
|
||||
Africa: 'africa',
|
||||
Asia: 'asia',
|
||||
Americas: 'south_america',
|
||||
};
|
||||
export const CONTINENT_REGION = {
|
||||
Africa: 'africa',
|
||||
Asia: 'asia',
|
||||
Europe: 'europe',
|
||||
'North America': 'north_america',
|
||||
'South America': 'south_america',
|
||||
Oceania: 'oceania',
|
||||
};
|
||||
// Transcontinental countries belong to every region they span.
|
||||
export const EXTRA_REGIONS = {
|
||||
RU: ['asia'],
|
||||
TR: ['europe'],
|
||||
KZ: ['europe'],
|
||||
GE: ['europe'],
|
||||
AM: ['europe'],
|
||||
AZ: ['europe'],
|
||||
CY: ['europe'],
|
||||
EG: ['asia'],
|
||||
};
|
||||
|
||||
// ISO codes GeoIP emits for places Natural Earth draws inside another country.
|
||||
export const COUNTRY_ALIASES = {
|
||||
GF: 'FR',
|
||||
GP: 'FR',
|
||||
MQ: 'FR',
|
||||
RE: 'FR',
|
||||
YT: 'FR',
|
||||
BQ: 'NL',
|
||||
SJ: 'NO',
|
||||
BV: 'NO',
|
||||
CX: 'AU',
|
||||
CC: 'AU',
|
||||
HM: 'AU',
|
||||
UM: 'US',
|
||||
TK: 'NZ',
|
||||
AC: 'SH',
|
||||
TA: 'SH',
|
||||
DG: 'IO',
|
||||
CP: 'FR',
|
||||
FX: 'FR',
|
||||
EA: 'ES',
|
||||
IC: 'ES',
|
||||
CQ: 'GB',
|
||||
GI: 'GB',
|
||||
// Retired or reserved codes still seen in older data.
|
||||
UK: 'GB',
|
||||
SU: 'RU',
|
||||
YU: 'RS',
|
||||
CS: 'RS',
|
||||
TP: 'TL',
|
||||
BU: 'MM',
|
||||
ZR: 'CD',
|
||||
DD: 'DE',
|
||||
VD: 'VN',
|
||||
YD: 'YE',
|
||||
RH: 'ZW',
|
||||
NH: 'VU',
|
||||
HV: 'BF',
|
||||
DY: 'BJ',
|
||||
AN: 'CW',
|
||||
};
|
||||
export const COUNTRY_NAME_ALIASES = {
|
||||
usa: 'US',
|
||||
'united states of america': 'US',
|
||||
america: 'US',
|
||||
uk: 'GB',
|
||||
'great britain': 'GB',
|
||||
britain: 'GB',
|
||||
england: 'GB',
|
||||
russia: 'RU',
|
||||
'south korea': 'KR',
|
||||
'north korea': 'KP',
|
||||
czechia: 'CZ',
|
||||
'czech republic': 'CZ',
|
||||
'ivory coast': 'CI',
|
||||
turkiye: 'TR',
|
||||
türkiye: 'TR',
|
||||
burma: 'MM',
|
||||
'cape verde': 'CV',
|
||||
swaziland: 'SZ',
|
||||
macedonia: 'MK',
|
||||
'east timor': 'TL',
|
||||
vatican: 'VA',
|
||||
};
|
||||
|
||||
// Alpha-2 codes Intl names that aren't countries, so none needs a shape.
|
||||
export const NON_COUNTRY_CODES = new Set([
|
||||
'EU',
|
||||
'EZ',
|
||||
'UN',
|
||||
'ZZ',
|
||||
'QO',
|
||||
'XA',
|
||||
'XB',
|
||||
'AQ',
|
||||
]);
|
||||
185
frontend/scripts/geo-outlines/countries.mjs
Normal file
185
frontend/scripts/geo-outlines/countries.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
// Natural Earth admin-0 → the countries topology and the lookup tables.
|
||||
import { geoArea, geoBounds } from 'd3-geo';
|
||||
import { feature } from 'topojson-client';
|
||||
|
||||
import {
|
||||
CONTINENT_REGION,
|
||||
COUNTRY_ALIASES,
|
||||
COUNTRY_MIN_WEIGHT,
|
||||
COUNTRY_NAME_ALIASES,
|
||||
COUNTRY_QUANTIZATION,
|
||||
EXTRA_REGIONS,
|
||||
NON_COUNTRY_CODES,
|
||||
OPEN_OCEAN_REGION,
|
||||
} from './config.mjs';
|
||||
import {
|
||||
polygonsOf,
|
||||
round,
|
||||
roundBounds,
|
||||
simplifyAndQuantize,
|
||||
} from './geometry.mjs';
|
||||
|
||||
export const isCountryCode = (value) => /^[A-Z]{2}$/.test(value ?? '');
|
||||
|
||||
function regionsOf(props, iso2) {
|
||||
const base = props.CONTINENT.startsWith('Seven seas')
|
||||
? OPEN_OCEAN_REGION[props.REGION_UN]
|
||||
: CONTINENT_REGION[props.CONTINENT];
|
||||
return [...new Set([base, ...(EXTRA_REGIONS[iso2] ?? [])].filter(Boolean))];
|
||||
}
|
||||
|
||||
// Group features by code so a country's overseas parts (Australia's Indian
|
||||
// Ocean territories) become one shape that one data row colours.
|
||||
function groupByCode(sourceFeatures, report) {
|
||||
const groups = new Map();
|
||||
for (const f of sourceFeatures) {
|
||||
const p = f.properties;
|
||||
if (p.CONTINENT === 'Antarctica') {
|
||||
report.dropped.push(p.NAME);
|
||||
continue;
|
||||
}
|
||||
const iso2 = isCountryCode(p.ISO_A2_EH) ? p.ISO_A2_EH : null;
|
||||
const key = iso2 ?? `uncoded:${p.NAME}`;
|
||||
if (!iso2) {
|
||||
report.uncoded.push(p.NAME);
|
||||
}
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key).push(f);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function toCountryFeature(key, members, report) {
|
||||
// The sovereign part carries the name, label point and region.
|
||||
const primary =
|
||||
members.find((f) => f.properties.ISO_A2 === f.properties.ISO_A2_EH) ??
|
||||
members.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
|
||||
const p = primary.properties;
|
||||
const iso2 = key.startsWith('uncoded:') ? null : key;
|
||||
if (members.length > 1) {
|
||||
report.merged.push(
|
||||
`${iso2}: ${members.map((f) => f.properties.NAME).join(' + ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const polygons = members.flatMap((f) => polygonsOf(f.geometry));
|
||||
const geometry = { type: 'MultiPolygon', coordinates: polygons };
|
||||
const mainland = polygons
|
||||
.map((coordinates) => ({ type: 'Polygon', coordinates }))
|
||||
.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: iso2 ?? p.NAME,
|
||||
properties: {
|
||||
code: iso2,
|
||||
iso3: /^[A-Z]{3}$/.test(p.ISO_A3_EH) ? p.ISO_A3_EH : null,
|
||||
name: p.NAME,
|
||||
label: [round(p.LABEL_X), round(p.LABEL_Y)],
|
||||
mainland: roundBounds(geoBounds(mainland)),
|
||||
bounds: roundBounds(geoBounds(geometry)),
|
||||
regions: regionsOf(p, iso2 ?? ''),
|
||||
},
|
||||
geometry,
|
||||
};
|
||||
}
|
||||
|
||||
/** Alpha-3 codes and English names → alpha-2, for normalising data. */
|
||||
function buildLookups(sourceFeatures) {
|
||||
const alpha3 = {};
|
||||
const names = { ...COUNTRY_NAME_ALIASES };
|
||||
for (const { properties: p } of sourceFeatures) {
|
||||
if (!isCountryCode(p.ISO_A2_EH) || p.CONTINENT === 'Antarctica') {
|
||||
continue;
|
||||
}
|
||||
if (/^[A-Z]{3}$/.test(p.ISO_A3_EH)) {
|
||||
alpha3[p.ISO_A3_EH] = p.ISO_A2_EH;
|
||||
}
|
||||
for (const name of [
|
||||
p.NAME,
|
||||
p.NAME_LONG,
|
||||
p.ADMIN,
|
||||
p.NAME_EN,
|
||||
p.BRK_NAME,
|
||||
p.NAME_SORT,
|
||||
p.NAME_CIAWF,
|
||||
]) {
|
||||
if (name) {
|
||||
names[name.toLowerCase()] ??= p.ISO_A2_EH;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { alpha3, names, aliases: COUNTRY_ALIASES };
|
||||
}
|
||||
|
||||
/** ISO alpha-2 codes Intl knows that neither a shape nor an alias covers. */
|
||||
function findUncoveredCodes(drawn) {
|
||||
const display = new Intl.DisplayNames(['en'], {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
});
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const uncovered = [];
|
||||
for (const a of letters) {
|
||||
for (const b of letters) {
|
||||
const code = a + b;
|
||||
const name = display.of(code);
|
||||
if (
|
||||
name &&
|
||||
!NON_COUNTRY_CODES.has(code) &&
|
||||
!drawn.has(code) &&
|
||||
!COUNTRY_ALIASES[code]
|
||||
) {
|
||||
uncovered.push(`${code} ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return uncovered;
|
||||
}
|
||||
|
||||
/** Natural Earth admin-0 GeoJSON → `{ topology, lookups, report }`. */
|
||||
export function buildCountries(source) {
|
||||
const report = { dropped: [], uncoded: [], merged: [] };
|
||||
const features = [...groupByCode(source.features, report)]
|
||||
.map(([key, members]) => toCountryFeature(key, members, report))
|
||||
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
||||
|
||||
const { topo, flipped, collapsed, emptied } = simplifyAndQuantize(
|
||||
'countries',
|
||||
features,
|
||||
COUNTRY_MIN_WEIGHT,
|
||||
COUNTRY_QUANTIZATION,
|
||||
);
|
||||
const drawn = new Set(features.map((f) => f.properties.code).filter(Boolean));
|
||||
// Lets data be checked against the drawn codes without decoding the outlines.
|
||||
const lookups = { codes: [...drawn].sort(), ...buildLookups(source.features) };
|
||||
|
||||
const regionCounts = {};
|
||||
for (const f of features) {
|
||||
for (const region of f.properties.regions) {
|
||||
regionCounts[region] = (regionCounts[region] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
topology: topo,
|
||||
lookups,
|
||||
report: {
|
||||
features: features.length,
|
||||
decodedFeatures: feature(topo, topo.objects.countries).features.length,
|
||||
droppedFlippedIslands: flipped,
|
||||
droppedCollapsedPolygons: collapsed,
|
||||
emptied: emptied.join(' '),
|
||||
regionCounts,
|
||||
...report,
|
||||
lookupCounts: {
|
||||
alpha3: Object.keys(lookups.alpha3).length,
|
||||
names: Object.keys(lookups.names).length,
|
||||
aliases: Object.keys(lookups.aliases).length,
|
||||
},
|
||||
uncoveredIsoCodes: findUncoveredCodes(drawn),
|
||||
},
|
||||
};
|
||||
}
|
||||
114
frontend/scripts/geo-outlines/geometry.mjs
Normal file
114
frontend/scripts/geo-outlines/geometry.mjs
Normal file
@@ -0,0 +1,114 @@
|
||||
// Geometry helpers and the simplification shared by countries and states.
|
||||
import { geoArea } from 'd3-geo';
|
||||
import { topology } from 'topojson-server';
|
||||
import { presimplify, simplify } from 'topojson-simplify';
|
||||
import { feature, quantize } from 'topojson-client';
|
||||
|
||||
import { PROTECTED_SHAPE_FACTOR } from './config.mjs';
|
||||
|
||||
export const round = (n) => Math.round(n * 1e3) / 1e3;
|
||||
export const roundBounds = (bounds) =>
|
||||
bounds.map(([x, y]) => [round(x), round(y)]);
|
||||
|
||||
export function polygonsOf(geometry) {
|
||||
if (geometry.type === 'Polygon') {
|
||||
return [geometry.coordinates];
|
||||
}
|
||||
if (geometry.type === 'MultiPolygon') {
|
||||
return geometry.coordinates;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Area of a lon/lat ring in square degrees, the unit of the simplify weights. */
|
||||
function planarRingArea(ring) {
|
||||
let twice = 0;
|
||||
ring.forEach(([x, y], i) => {
|
||||
const [nx, ny] = ring[(i + 1) % ring.length];
|
||||
twice += x * ny - nx * y;
|
||||
});
|
||||
return Math.abs(twice) / 2;
|
||||
}
|
||||
|
||||
// Weights every point of a small shape's largest outline as Infinity, which
|
||||
// simplify never removes, so a city-state or city-sized province (Monaco, a
|
||||
// London borough) still has an outline to colour. Neighbours share those arcs,
|
||||
// so their common border stays in step.
|
||||
function protectSmallShapes(topo, objectName, features, minWeight) {
|
||||
topo.objects[objectName].geometries.forEach((geometry, i) => {
|
||||
const polygons = polygonsOf(features[i].geometry);
|
||||
const areas = polygons.map(([outer]) => planarRingArea(outer));
|
||||
const total = areas.reduce((sum, area) => sum + area, 0);
|
||||
if (polygons.length === 0 || total > minWeight * PROTECTED_SHAPE_FACTOR) {
|
||||
return;
|
||||
}
|
||||
const largest = areas.indexOf(Math.max(...areas));
|
||||
const rings =
|
||||
geometry.type === 'Polygon' ? geometry.arcs : geometry.arcs[largest];
|
||||
rings[0].forEach((index) => {
|
||||
topo.arcs[index < 0 ? ~index : index].forEach((point) => {
|
||||
point[2] = Infinity;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const isCollapsed = ([outer]) =>
|
||||
new Set(outer.map((point) => point.join())).size < 3;
|
||||
|
||||
// Simplifying can collapse a tiny island until its ring runs the other way; d3
|
||||
// then reads it as "the whole globe except this island" and floods the map.
|
||||
const isInverted = (coordinates) =>
|
||||
geoArea({ type: 'Polygon', coordinates }) > 2 * Math.PI;
|
||||
|
||||
/**
|
||||
* One feature collection to a simplified, quantized topology under
|
||||
* `objectName`. Polygons that collapse to a sliver or flip inside out are
|
||||
* dropped and the topology rebuilt without them.
|
||||
*/
|
||||
export function simplifyAndQuantize(
|
||||
objectName,
|
||||
features,
|
||||
minWeight,
|
||||
quantization,
|
||||
) {
|
||||
let topo = presimplify(
|
||||
topology({ [objectName]: { type: 'FeatureCollection', features } }),
|
||||
);
|
||||
protectSmallShapes(topo, objectName, features, minWeight);
|
||||
topo = simplify(topo, minWeight);
|
||||
// presimplify stores each point's weight as a third coordinate and simplify
|
||||
// keeps it, so it is dropped before quantizing (which would carry it through).
|
||||
topo = { ...topo, arcs: topo.arcs.map((arc) => arc.map(([x, y]) => [x, y])) };
|
||||
topo = quantize(topo, quantization);
|
||||
|
||||
let flipped = 0;
|
||||
let collapsed = 0;
|
||||
const cleaned = feature(topo, topo.objects[objectName]).features.map((f) => {
|
||||
const kept = polygonsOf(f.geometry).filter((coordinates) => {
|
||||
if (isCollapsed(coordinates)) {
|
||||
collapsed += 1;
|
||||
return false;
|
||||
}
|
||||
if (isInverted(coordinates)) {
|
||||
flipped += 1;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { ...f, geometry: { type: 'MultiPolygon', coordinates: kept } };
|
||||
});
|
||||
const emptied = cleaned
|
||||
.filter((f) => f.geometry.coordinates.length === 0)
|
||||
.map((f) => f.properties.code ?? f.properties.name);
|
||||
|
||||
if (flipped > 0 || collapsed > 0) {
|
||||
topo = quantize(
|
||||
topology({
|
||||
[objectName]: { type: 'FeatureCollection', features: cleaned },
|
||||
}),
|
||||
quantization,
|
||||
);
|
||||
}
|
||||
return { topo, flipped, collapsed, emptied };
|
||||
}
|
||||
188
frontend/scripts/geo-outlines/subdivisions.mjs
Normal file
188
frontend/scripts/geo-outlines/subdivisions.mjs
Normal file
@@ -0,0 +1,188 @@
|
||||
// Natural Earth admin-1 → one states/provinces topology per country.
|
||||
import { geoArea } from 'd3-geo';
|
||||
import { topology } from 'topojson-server';
|
||||
import { merge } from 'topojson-client';
|
||||
|
||||
import {
|
||||
MIN_SUBDIVISIONS,
|
||||
SUBDIVISION_ALIASES,
|
||||
SUBDIVISION_MERGES,
|
||||
SUBDIVISION_MIN_WEIGHT,
|
||||
SUBDIVISION_QUANTIZATION,
|
||||
} from './config.mjs';
|
||||
import { isCountryCode } from './countries.mjs';
|
||||
import { round, simplifyAndQuantize } from './geometry.mjs';
|
||||
|
||||
const SUBDIVISION_CODE = /^[A-Z]{2}-[A-Z0-9]{1,3}$/;
|
||||
|
||||
/** Retired code → `[current code, current name]`, from `SUBDIVISION_MERGES`. */
|
||||
const RECODES = new Map(
|
||||
Object.values(SUBDIVISION_MERGES).flatMap((merges) =>
|
||||
Object.entries(merges).flatMap(([code, [name, retired]]) =>
|
||||
retired.map((old) => [old, [code, name]]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/** A country's aliases: the hand-written ones, then each merge's retired codes. */
|
||||
function aliasesOf(country) {
|
||||
const aliases = { ...SUBDIVISION_ALIASES[country] };
|
||||
for (const [code, [, retired]] of Object.entries(
|
||||
SUBDIVISION_MERGES[country] ?? {},
|
||||
)) {
|
||||
for (const old of retired) {
|
||||
if (old !== code) {
|
||||
aliases[old] = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
// Shapes without an ISO 3166-2 code can't be matched to data, and a code under
|
||||
// another country's prefix (Puerto Rico's `US-PR`) belongs to that country's
|
||||
// file. Retired codes are rewritten to the current ones here, so the merge
|
||||
// below dissolves them.
|
||||
function groupByCountry(sourceFeatures) {
|
||||
const byCountry = new Map();
|
||||
const uncoded = {};
|
||||
const recoded = new Set();
|
||||
for (const f of sourceFeatures) {
|
||||
const country = f.properties.iso_a2;
|
||||
if (!isCountryCode(country)) {
|
||||
continue;
|
||||
}
|
||||
const code = f.properties.iso_3166_2;
|
||||
if (!SUBDIVISION_CODE.test(code ?? '') || !code.startsWith(`${country}-`)) {
|
||||
uncoded[country] = (uncoded[country] ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
if (!byCountry.has(country)) {
|
||||
byCountry.set(country, []);
|
||||
}
|
||||
const recode = RECODES.get(code);
|
||||
if (recode) {
|
||||
recoded.add(code);
|
||||
}
|
||||
byCountry.get(country).push(
|
||||
recode
|
||||
? {
|
||||
...f,
|
||||
properties: {
|
||||
...f.properties,
|
||||
iso_3166_2: recode[0],
|
||||
name: recode[1],
|
||||
},
|
||||
}
|
||||
: f,
|
||||
);
|
||||
}
|
||||
return { byCountry, uncoded, recoded };
|
||||
}
|
||||
|
||||
// Natural Earth splits some codes across shapes (Dublin's four councils are all
|
||||
// `IE-D`). Merging them through a topology drops the borders between the parts,
|
||||
// so one code is one shape with one outline.
|
||||
function mergeByCode(features) {
|
||||
const byCode = new Map();
|
||||
for (const f of features) {
|
||||
const code = f.properties.iso_3166_2;
|
||||
if (!byCode.has(code)) {
|
||||
byCode.set(code, []);
|
||||
}
|
||||
byCode.get(code).push(f);
|
||||
}
|
||||
const topo = topology({ parts: { type: 'FeatureCollection', features } });
|
||||
const { geometries } = topo.objects.parts;
|
||||
let merged = 0;
|
||||
const shapes = [...byCode].map(([code, members]) => {
|
||||
// The largest part carries the name and label point.
|
||||
const primary = members.reduce((a, b) => (geoArea(a) >= geoArea(b) ? a : b));
|
||||
let geometry = primary.geometry;
|
||||
if (members.length > 1) {
|
||||
merged += members.length - 1;
|
||||
geometry = merge(
|
||||
topo,
|
||||
geometries.filter((_, i) => features[i].properties.iso_3166_2 === code),
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: code,
|
||||
properties: {
|
||||
code,
|
||||
name: primary.properties.name,
|
||||
label: [
|
||||
round(primary.properties.longitude),
|
||||
round(primary.properties.latitude),
|
||||
],
|
||||
},
|
||||
geometry,
|
||||
};
|
||||
});
|
||||
return { shapes: shapes.sort((a, b) => a.id.localeCompare(b.id)), merged };
|
||||
}
|
||||
|
||||
/** Lower-cased English names → code, for data that names a state. */
|
||||
function namesOf(members) {
|
||||
const names = {};
|
||||
for (const { properties: p } of members) {
|
||||
for (const name of [p.name, p.name_en, p.gn_name, p.woe_name]) {
|
||||
if (name) {
|
||||
names[name.toLowerCase()] ??= p.iso_3166_2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural Earth admin-1 GeoJSON → `{ files, report }`, where `files` maps each
|
||||
* country with enough coded states to its outlines file, in code order.
|
||||
*/
|
||||
export function buildSubdivisions(source) {
|
||||
const { byCountry, uncoded, recoded } = groupByCountry(source.features);
|
||||
const files = new Map();
|
||||
const report = {
|
||||
skippedUnderMinimum: [],
|
||||
mergedParts: {},
|
||||
uncodedShapes: uncoded,
|
||||
droppedFlippedIslands: {},
|
||||
droppedCollapsedPolygons: 0,
|
||||
emptiedShapes: [],
|
||||
// Merge entries whose retired code Natural Earth no longer has.
|
||||
unmatchedRetiredCodes: [...RECODES.keys()].filter(
|
||||
(code) => !recoded.has(code),
|
||||
),
|
||||
};
|
||||
|
||||
const countries = [...byCountry.keys()].sort((a, b) => a.localeCompare(b));
|
||||
for (const country of countries) {
|
||||
const members = byCountry.get(country);
|
||||
const { shapes, merged } = mergeByCode(members);
|
||||
if (shapes.length < MIN_SUBDIVISIONS) {
|
||||
report.skippedUnderMinimum.push(country);
|
||||
continue;
|
||||
}
|
||||
if (merged) {
|
||||
report.mergedParts[country] = merged;
|
||||
}
|
||||
const { topo, flipped, collapsed, emptied } = simplifyAndQuantize(
|
||||
'subdivisions',
|
||||
shapes,
|
||||
SUBDIVISION_MIN_WEIGHT,
|
||||
SUBDIVISION_QUANTIZATION,
|
||||
);
|
||||
if (flipped) {
|
||||
report.droppedFlippedIslands[country] = flipped;
|
||||
}
|
||||
report.droppedCollapsedPolygons += collapsed;
|
||||
report.emptiedShapes.push(...emptied);
|
||||
files.set(country, {
|
||||
...topo,
|
||||
aliases: aliasesOf(country),
|
||||
names: namesOf(members),
|
||||
});
|
||||
}
|
||||
return { files, report };
|
||||
}
|
||||
110
frontend/scripts/geo-outlines/validate.mjs
Normal file
110
frontend/scripts/geo-outlines/validate.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
// Invariants the chart relies on, checked before anything is written. A
|
||||
// failure names every problem; the committed assets are left untouched.
|
||||
import { feature } from 'topojson-client';
|
||||
|
||||
import { MIN_SUBDIVISIONS } from './config.mjs';
|
||||
|
||||
// Natural Earth v5.1.2 yields 239 countries and 198 countries with states. A
|
||||
// drop below these means a changed or truncated download, not real geography.
|
||||
const MIN_COUNTRIES = 230;
|
||||
const MIN_SUBDIVISION_COUNTRIES = 190;
|
||||
|
||||
function duplicatesOf(values) {
|
||||
const seen = new Set();
|
||||
return values.filter((value) => seen.has(value) || !seen.add(value));
|
||||
}
|
||||
|
||||
function checkCountries({ topology, lookups, report }, problems) {
|
||||
const shapes = feature(topology, topology.objects.countries).features;
|
||||
const codes = shapes.map((shape) => shape.properties.code).filter(Boolean);
|
||||
const drawn = new Set(codes);
|
||||
|
||||
if (shapes.length < MIN_COUNTRIES) {
|
||||
problems.push(
|
||||
`only ${shapes.length} countries (expected ≥ ${MIN_COUNTRIES})`,
|
||||
);
|
||||
}
|
||||
if (report.emptied) {
|
||||
problems.push(`countries left with no outline: ${report.emptied}`);
|
||||
}
|
||||
for (const code of duplicatesOf(codes)) {
|
||||
problems.push(`country code ${code} is drawn twice`);
|
||||
}
|
||||
if (lookups.codes.join() !== [...drawn].sort().join()) {
|
||||
problems.push('lookups.codes differs from the drawn country codes');
|
||||
}
|
||||
for (const [alias, target] of Object.entries(lookups.aliases)) {
|
||||
if (!drawn.has(target)) {
|
||||
problems.push(`country alias ${alias} → ${target}, which has no shape`);
|
||||
}
|
||||
}
|
||||
for (const [alpha3, target] of Object.entries(lookups.alpha3)) {
|
||||
if (!drawn.has(target)) {
|
||||
problems.push(`alpha-3 ${alpha3} → ${target}, which has no shape`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkSubdivisionFile(country, file, problems) {
|
||||
const codes = file.objects.subdivisions.geometries.map(
|
||||
(geometry) => geometry.properties.code,
|
||||
);
|
||||
const known = new Set(codes);
|
||||
|
||||
if (codes.length < MIN_SUBDIVISIONS) {
|
||||
problems.push(
|
||||
`${country}: ${codes.length} states (expected ≥ ${MIN_SUBDIVISIONS})`,
|
||||
);
|
||||
}
|
||||
for (const code of duplicatesOf(codes)) {
|
||||
problems.push(`${country}: state code ${code} is drawn twice`);
|
||||
}
|
||||
for (const code of codes) {
|
||||
if (!code?.startsWith(`${country}-`)) {
|
||||
problems.push(`${country}: state code ${code} is outside the country`);
|
||||
}
|
||||
}
|
||||
for (const [alias, target] of Object.entries(file.aliases)) {
|
||||
if (!known.has(target)) {
|
||||
problems.push(`${country}: alias ${alias} → ${target}, which has no shape`);
|
||||
}
|
||||
}
|
||||
for (const [name, code] of Object.entries(file.names)) {
|
||||
if (!known.has(code)) {
|
||||
problems.push(`${country}: name "${name}" → ${code}, which has no shape`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkSubdivisions({ files, report }, problems) {
|
||||
if (files.size < MIN_SUBDIVISION_COUNTRIES) {
|
||||
problems.push(
|
||||
`only ${files.size} countries with states (expected ≥ ${MIN_SUBDIVISION_COUNTRIES})`,
|
||||
);
|
||||
}
|
||||
if (report.emptiedShapes.length > 0) {
|
||||
problems.push(
|
||||
`states left with no outline: ${report.emptiedShapes.join(' ')}`,
|
||||
);
|
||||
}
|
||||
if (report.unmatchedRetiredCodes.length > 0) {
|
||||
problems.push(
|
||||
`merge codes not found in Natural Earth: ${report.unmatchedRetiredCodes.join(' ')}`,
|
||||
);
|
||||
}
|
||||
for (const [country, file] of files) {
|
||||
checkSubdivisionFile(country, file, problems);
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws one error listing every broken invariant. */
|
||||
export function validate(countries, subdivisions) {
|
||||
const problems = [];
|
||||
checkCountries(countries, problems);
|
||||
checkSubdivisions(subdivisions, problems);
|
||||
if (problems.length > 0) {
|
||||
throw new Error(
|
||||
`${problems.length} problem(s) in the generated outlines:\n- ${problems.join('\n- ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -41,17 +41,16 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
message: data?.error,
|
||||
};
|
||||
}
|
||||
// Avoid logging error when the request was just cancelled for whatever reason
|
||||
if (isCancel(error)) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
if (request) {
|
||||
// Avoid logging error when the request was just cancelled for whatever reason
|
||||
if (isCancel(error)) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
// client never received a response, or request never left
|
||||
console.error('client never received a response, or request never left');
|
||||
|
||||
|
||||
22
frontend/src/api/__tests__/ErrorResponseHandler.test.ts
Normal file
22
frontend/src/api/__tests__/ErrorResponseHandler.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError, CanceledError } from 'axios';
|
||||
|
||||
describe('ErrorResponseHandler', () => {
|
||||
// axios aborts a request whose signal fired before it was sent without ever
|
||||
// creating one, so the error carries neither `response` nor `request`.
|
||||
it('treats a request cancelled before it was sent as a cancellation', () => {
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toEqual({
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
});
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -261,7 +261,7 @@ export const invalidateGetTraceSummary = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* Returns the gen_ai spans of a trace, model calls, tool calls and agent spans, in timestamp order. The messages a span carries are decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve; a span without messages has both empty. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
@@ -475,7 +475,7 @@ export const useGetFlamegraph = <
|
||||
return useMutation(getGetFlamegraphMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3
|
||||
* Returns the waterfall view of spans including all spans if total spans are under a limit, a max count otherwise. Aggregations are dropped compared to v3. A span carrying gen_ai input or output messages also has them decoded into formattedInput and formattedOutput, with formatterWarnings holding what the converter could not resolve.
|
||||
* @summary Get waterfall view for a trace
|
||||
*/
|
||||
export const getWaterfallV4 = (
|
||||
|
||||
@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
convertBuilderQueriesToV5,
|
||||
mapPanelTypeToRequestType,
|
||||
prepareQueryRangePayloadV5,
|
||||
} from './prepareQueryRangePayloadV5';
|
||||
|
||||
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapPanelTypeToRequestType', () => {
|
||||
it('sends a scatter plot as scalar, like a table', () => {
|
||||
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertBuilderQueriesToV5 having normalization', () => {
|
||||
const buildSpec = (having: unknown): MetricBuilderQuery => {
|
||||
const [envelope] = convertBuilderQueriesToV5(
|
||||
|
||||
@@ -51,8 +51,10 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
|
||||
case PANEL_TYPES.BAR:
|
||||
return 'time_series';
|
||||
case PANEL_TYPES.TABLE:
|
||||
case PANEL_TYPES.SCATTER:
|
||||
case PANEL_TYPES.PIE:
|
||||
case PANEL_TYPES.VALUE:
|
||||
case PANEL_TYPES.TOP_LIST:
|
||||
return 'scalar';
|
||||
case PANEL_TYPES.TRACE:
|
||||
return 'trace';
|
||||
@@ -278,8 +280,10 @@ export function createAggregation(
|
||||
queryData.dataSource === DataSource.METRICS &&
|
||||
panelType &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE);
|
||||
panelType === PANEL_TYPES.VALUE ||
|
||||
panelType === PANEL_TYPES.TOP_LIST);
|
||||
|
||||
if (queryData.dataSource === DataSource.METRICS) {
|
||||
return [
|
||||
|
||||
@@ -17,7 +17,10 @@ function ModalFixture(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
data-testid="open-add-credit-card"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="open-add-credit-card"
|
||||
onClick={(): void => setOpen(true)}
|
||||
>
|
||||
Contact support
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
:global(.ant-breadcrumb-separator) {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
[data-slot='button'] {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
.item {
|
||||
--button-padding: 0;
|
||||
--button-font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
|
||||
.itemLast {
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--button-secondary-label);
|
||||
font-size: var(--button-font-size, var(--periscope-font-size-base));
|
||||
line-height: var(--button-size-line-height, 16px);
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ function BreadcrumbItem({
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
className={styles.item}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
if (!('route' in props) || !props.route) {
|
||||
return;
|
||||
|
||||
@@ -34,21 +34,23 @@ function ErrorEmptyState({
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
onClick={onContactSupport}
|
||||
data-testid="error-contact-support-button"
|
||||
testId="error-contact-support-button"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="error-refresh-button"
|
||||
testId="error-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
.labelColumn {
|
||||
// Badges fill their wrapper; the wrapper, as the flex item, owns the cap.
|
||||
--badge-max-width: 100%;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
|
||||
> * {
|
||||
flex-shrink: 0;
|
||||
max-width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
// Kept mounted so its width can still be measured.
|
||||
.overflowed {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
--badge-display: inline;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
// The only visible label truncates instead of pushing `+N` out of view.
|
||||
.labelColumn > .shrinkable {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.overflowTrigger {
|
||||
@@ -22,11 +34,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overflowBadge {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.labelPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
|
||||
import LabelColumn from './LabelColumn';
|
||||
|
||||
let resizeCallback: ResizeObserverCallback | null = null;
|
||||
const BADGE_WIDTH = 60;
|
||||
|
||||
let resizeCallbacks: ResizeObserverCallback[] = [];
|
||||
|
||||
class MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback;
|
||||
resizeCallbacks.push(callback);
|
||||
}
|
||||
|
||||
observe = jest.fn();
|
||||
@@ -15,15 +16,23 @@ class MockResizeObserver {
|
||||
disconnect = jest.fn();
|
||||
}
|
||||
|
||||
function triggerResize(width: number): void {
|
||||
if (resizeCallback) {
|
||||
act(() => {
|
||||
resizeCallback?.(
|
||||
[{ contentRect: { width } } as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
// jsdom has no layout: every badge measures BADGE_WIDTH, the column `containerWidth`.
|
||||
function mockLayout(containerWidth: number): void {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
||||
.mockImplementation(function getClientWidth(this: HTMLElement) {
|
||||
return this.dataset.testid === 'label-column' ? containerWidth : 0;
|
||||
});
|
||||
}
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockReturnValue({ width: BADGE_WIDTH } as DOMRect);
|
||||
}
|
||||
|
||||
function triggerResize(containerWidth: number): void {
|
||||
mockLayout(containerWidth);
|
||||
act(() => {
|
||||
resizeCallbacks.forEach((callback) => callback([], {} as ResizeObserver));
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -31,13 +40,14 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resizeCallback = null;
|
||||
resizeCallbacks = [];
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
): ReturnType<typeof render> {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
return render(ui);
|
||||
}
|
||||
|
||||
describe('LabelColumn', () => {
|
||||
@@ -54,13 +64,10 @@ describe('LabelColumn', () => {
|
||||
it('should truncate labels and show +N badge when container is narrow', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
// 3 badges (180px) + overflow reserve (40px)
|
||||
mockLayout(220);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container that fits ~3 badges
|
||||
// Badge widths: env=37, service=65, region=58, team=44, owner=51, version=65
|
||||
// 220px available = 3 badges (160px) + gaps (8px) + overflow (44px)
|
||||
triggerResize(220);
|
||||
|
||||
// First 3 visible
|
||||
expect(screen.getByTestId('label-tag-env')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('label-tag-service')).toBeInTheDocument();
|
||||
@@ -93,19 +100,37 @@ describe('LabelColumn', () => {
|
||||
expect(screen.getByTestId('label-tag-service')).toHaveTextContent('service');
|
||||
});
|
||||
|
||||
it('should show overflow badge with remaining count when container is narrow', () => {
|
||||
it('should update the overflow count when the container resizes', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
mockLayout(220);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+3');
|
||||
|
||||
// 2 badges (120px) + overflow reserve (40px)
|
||||
triggerResize(160);
|
||||
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+4');
|
||||
|
||||
triggerResize(1000);
|
||||
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should skip the overflow reserve when every label fits', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
mockLayout(labels.length * BADGE_WIDTH);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate narrow container to trigger overflow (shows 3 labels)
|
||||
// 220px fits first 3 badges before overflow
|
||||
triggerResize(220);
|
||||
expect(screen.queryByTestId('label-overflow-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Overflow badge shows +3 (remaining labels)
|
||||
const overflowBadge = screen.getByTestId('label-overflow-badge');
|
||||
expect(overflowBadge).toBeInTheDocument();
|
||||
expect(overflowBadge).toHaveTextContent('+3');
|
||||
it('should keep one label visible when even that one does not fit', () => {
|
||||
const labels = ['env', 'service'];
|
||||
|
||||
mockLayout(50);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
expect(screen.getByTestId('label-overflow-badge')).toHaveTextContent('+1');
|
||||
});
|
||||
|
||||
it('should render empty when no labels provided', () => {
|
||||
@@ -126,11 +151,9 @@ describe('LabelColumn', () => {
|
||||
it('should show all labels when container is wide enough', () => {
|
||||
const labels = ['env', 'service', 'region', 'team', 'owner', 'version'];
|
||||
|
||||
mockLayout(1000);
|
||||
renderWithProviders(<LabelColumn labels={labels} />);
|
||||
|
||||
// Simulate wide container
|
||||
triggerResize(1000);
|
||||
|
||||
// All labels visible
|
||||
labels.forEach((label) => {
|
||||
expect(screen.getByTestId(`label-tag-${label}`)).toBeInTheDocument();
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import LabelTag from './LabelTag';
|
||||
|
||||
import styles from './LabelColumn.module.scss';
|
||||
import { BADGE_GAP, estimateBadgeWidth, OVERFLOW_BADGE_WIDTH } from './utils';
|
||||
import { getVisibleCount } from './utils';
|
||||
|
||||
export interface LabelColumnProps {
|
||||
labels: string[];
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
color?: BadgeColorType;
|
||||
value?: { [key: string]: string };
|
||||
}
|
||||
|
||||
@@ -39,60 +23,37 @@ function LabelColumn({
|
||||
color = 'primary',
|
||||
}: LabelColumnProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [maxVisibleCount, setMaxVisibleCount] = useState(labels.length);
|
||||
const [visibleCount, setVisibleCount] = useState(labels.length);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const calculateMaxVisible = useCallback(
|
||||
(width: number): number => {
|
||||
if (width <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const availableWidth = width - OVERFLOW_BADGE_WIDTH - BADGE_GAP;
|
||||
let usedWidth = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const label of labels) {
|
||||
const badgeWidth = estimateBadgeWidth(label, value?.[label]) + BADGE_GAP;
|
||||
if (usedWidth + badgeWidth > availableWidth && count > 0) {
|
||||
break;
|
||||
}
|
||||
usedWidth += badgeWidth;
|
||||
count++;
|
||||
}
|
||||
|
||||
return Math.max(1, count);
|
||||
},
|
||||
[labels, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry && entry.contentRect.width > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(entry.contentRect.width));
|
||||
const measure = (): void => {
|
||||
const available = container.clientWidth;
|
||||
if (available <= 0) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const widths = Array.from(container.children)
|
||||
.slice(0, labels.length)
|
||||
.map((item) => item.getBoundingClientRect().width);
|
||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 0;
|
||||
|
||||
setVisibleCount(getVisibleCount(widths, available, gap));
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(container);
|
||||
|
||||
if (container.clientWidth > 0) {
|
||||
setMaxVisibleCount(calculateMaxVisible(container.clientWidth));
|
||||
}
|
||||
measure();
|
||||
|
||||
return (): void => observer.disconnect();
|
||||
}, [calculateMaxVisible]);
|
||||
}, [labels, value]);
|
||||
|
||||
const needsOverflow = labels.length > maxVisibleCount;
|
||||
const visibleLabels = needsOverflow
|
||||
? labels.slice(0, maxVisibleCount)
|
||||
: labels;
|
||||
const remainingLabels = needsOverflow ? labels.slice(maxVisibleCount) : [];
|
||||
const remainingLabels = labels.slice(visibleCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -100,24 +61,23 @@ function LabelColumn({
|
||||
className={styles.labelColumn}
|
||||
data-testid="label-column"
|
||||
>
|
||||
{visibleLabels.map((label) => (
|
||||
<LabelTag key={label} label={label} color={color} value={value?.[label]} />
|
||||
{labels.map((label, index) => (
|
||||
<LabelTag
|
||||
key={label}
|
||||
label={label}
|
||||
color={color}
|
||||
value={value?.[label]}
|
||||
className={cx({
|
||||
[styles.overflowed]: index >= visibleCount,
|
||||
[styles.shrinkable]: index === 0 && visibleCount === 1,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
{remainingLabels.length > 0 && (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.overflowBadge}
|
||||
variant="outline"
|
||||
data-testid="label-overflow-badge"
|
||||
>
|
||||
+{remainingLabels.length}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end">
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
align="end"
|
||||
title={
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>
|
||||
{remainingLabels
|
||||
@@ -140,8 +100,14 @@ function LabelColumn({
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Badge color={color} variant="outlined" testId="label-overflow-badge">
|
||||
+{remainingLabels.length}
|
||||
</Badge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
.labelBadge {
|
||||
cursor: default;
|
||||
font-size: 12px;
|
||||
|
||||
max-width: 180px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.labelValue {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import { Copy } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import styles from './LabelTag.module.scss';
|
||||
|
||||
export interface LabelTagProps {
|
||||
label: string;
|
||||
color?:
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua'
|
||||
| 'vanilla';
|
||||
color?: BadgeColorType;
|
||||
value?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
|
||||
function LabelTag({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
className,
|
||||
}: LabelTagProps): JSX.Element {
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
const searchFormat = value ? `${label} ${value}` : label;
|
||||
@@ -41,20 +30,8 @@ function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Badge
|
||||
color={color}
|
||||
className={styles.labelBadge}
|
||||
variant="outline"
|
||||
data-testid={`label-tag-${label}`}
|
||||
>
|
||||
<span className={styles.labelValue}>{displayText}</span>
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.tooltipContent}>
|
||||
<span>{displayText}</span>
|
||||
<button
|
||||
@@ -66,8 +43,19 @@ function LabelTag({ label, value, color }: LabelTagProps): JSX.Element {
|
||||
<Copy size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
}
|
||||
>
|
||||
<span className={className}>
|
||||
<Badge
|
||||
textTransform="none"
|
||||
color={color ?? 'secondary'}
|
||||
variant="outlined"
|
||||
testId={`label-tag-${label}`}
|
||||
>
|
||||
<span className={styles.labelValue}>{displayText}</span>
|
||||
</Badge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
export const BADGE_GAP = 4;
|
||||
export const OVERFLOW_BADGE_WIDTH = 40;
|
||||
|
||||
export const BADGE_MAX_WIDTH = 180;
|
||||
export const BADGE_PADDING = 16;
|
||||
export const CHAR_WIDTH = 7;
|
||||
|
||||
export function estimateBadgeWidth(label: string, value?: string): number {
|
||||
const displayText = value ? `${label}: ${value}` : label;
|
||||
return Math.min(
|
||||
displayText.length * CHAR_WIDTH + BADGE_PADDING,
|
||||
BADGE_MAX_WIDTH,
|
||||
/**
|
||||
* How many leading labels fit in `available` px, reserving room for the `+N` badge
|
||||
* whenever some are left out. At least one label is always shown.
|
||||
*/
|
||||
export function getVisibleCount(
|
||||
widths: number[],
|
||||
available: number,
|
||||
gap: number,
|
||||
): number {
|
||||
const total = widths.reduce(
|
||||
(sum, width, index) => sum + width + (index > 0 ? gap : 0),
|
||||
0,
|
||||
);
|
||||
if (total <= available) {
|
||||
return widths.length;
|
||||
}
|
||||
|
||||
const limit = available - OVERFLOW_BADGE_WIDTH - gap;
|
||||
let used = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const width of widths) {
|
||||
used += width + (count > 0 ? gap : 0);
|
||||
if (used > limit) {
|
||||
break;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
@@ -30,21 +30,23 @@ function NoResultsEmptyState({
|
||||
<div className={styles.actions}>
|
||||
{onClear && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onClear}
|
||||
data-testid="no-results-clear-button"
|
||||
testId="no-results-clear-button"
|
||||
>
|
||||
{clearButtonText}
|
||||
</Button>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<RefreshCw size={14} />}
|
||||
onClick={onRefresh}
|
||||
data-testid="no-results-refresh-button"
|
||||
testId="no-results-refresh-button"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BadgeColor } from '@signozhq/ui/badge';
|
||||
import type { BadgeColorType } from '@signozhq/ui/badge';
|
||||
|
||||
export const STATE_ORDER = ['firing', 'pending', 'inactive', 'disabled'];
|
||||
export const SEVERITY_ORDER = ['critical', 'error', 'warning', 'info'];
|
||||
@@ -24,9 +24,9 @@ export const SEVERITY_COLORS: Record<string, string> = {
|
||||
info: 'var(--bg-robin-500)',
|
||||
};
|
||||
|
||||
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColor> = {
|
||||
critical: 'error',
|
||||
error: 'error',
|
||||
export const SEVERITY_BADGE_COLORS: Record<string, BadgeColorType> = {
|
||||
critical: 'danger',
|
||||
error: 'danger',
|
||||
warning: 'warning',
|
||||
info: 'primary',
|
||||
};
|
||||
|
||||
@@ -33,36 +33,3 @@
|
||||
color: var(--l1-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-header-help-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 10px 16px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
span {
|
||||
font-family: var(--font-family-inter, Inter, sans-serif);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--l2-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ function AuthHeader(): JSX.Element {
|
||||
<span className="auth-header-logo-text">SigNoz</span>
|
||||
</div>
|
||||
<Button
|
||||
className="auth-header-help-button"
|
||||
size="md"
|
||||
prefix={<LifeBuoy size={12} />}
|
||||
onClick={handleGetHelp}
|
||||
variant="solid"
|
||||
color="none"
|
||||
color="secondary"
|
||||
>
|
||||
Get Help
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { Check, Plus, X } from '@signozhq/icons';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Pill } from '@signozhq/ui/pill';
|
||||
import Input from 'components/Input';
|
||||
|
||||
import './Badges.styles.scss';
|
||||
@@ -47,18 +47,13 @@ function Badges({ tags, setTags }: AddTagsProps): JSX.Element {
|
||||
return (
|
||||
<div className="tags-container">
|
||||
{tags.map<React.ReactNode>((tag) => (
|
||||
<Badge
|
||||
<Pill.Closeable
|
||||
textTransform="none"
|
||||
key={tag}
|
||||
color="vanilla"
|
||||
style={{ userSelect: 'none' }}
|
||||
closable
|
||||
onClose={(e): void => {
|
||||
e.preventDefault();
|
||||
handleClose(tag);
|
||||
}}
|
||||
onClose={() => handleClose(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
</Pill.Closeable>
|
||||
))}
|
||||
|
||||
{inputVisible && (
|
||||
|
||||
@@ -39,9 +39,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cloud-service-data-collected-table-tooltip {
|
||||
max-width: 280px;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
CloudintegrationtypesCollectedMetricDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { BarChart, Info, ScrollText } from '@signozhq/icons';
|
||||
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
|
||||
import './CloudServiceDataCollected.styles.scss';
|
||||
|
||||
@@ -88,23 +88,15 @@ function CloudServiceDataCollected({
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
{metricsInfoTooltip && (
|
||||
<TooltipProvider>
|
||||
<TooltipSimple
|
||||
title={metricsInfoTooltip}
|
||||
side="top"
|
||||
tooltipContentProps={{
|
||||
className: 'cloud-service-data-collected-table-tooltip',
|
||||
}}
|
||||
<Tooltip title={metricsInfoTooltip} side="top">
|
||||
<span
|
||||
className="cloud-service-data-collected-table-heading-info"
|
||||
aria-label="About the metrics listed below"
|
||||
data-testid="data-collected-metrics-info"
|
||||
>
|
||||
<span
|
||||
className="cloud-service-data-collected-table-heading-info"
|
||||
aria-label="About the metrics listed below"
|
||||
data-testid="data-collected-metrics-info"
|
||||
>
|
||||
<Info size={12} />
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
</TooltipProvider>
|
||||
<Info size={12} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Table
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.codeBlockSyntaxHighlighter {
|
||||
background-color: var(--l2-background) !important;
|
||||
border-radius: 4px !important;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Check, Copy } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import SyntaxHighlighter, {
|
||||
a11yDark,
|
||||
} from 'components/MarkdownRenderer/syntaxHighlighter';
|
||||
@@ -52,16 +53,20 @@ function CodeBlock({
|
||||
data-testid="code-block-container"
|
||||
>
|
||||
{showCopyButton ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
prefix={isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
aria-label="Copy code"
|
||||
title={isCopied ? 'Copied' : 'Copy'}
|
||||
style={{ position: 'absolute', right: 8, top: 8, zIndex: 1 }}
|
||||
/>
|
||||
<div className={styles.copyButton}>
|
||||
<Tooltip title={isCopied ? 'Copied' : 'Copy'}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{isCopied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
<SyntaxHighlighter
|
||||
style={a11yDark}
|
||||
|
||||
@@ -134,26 +134,27 @@ function CreateServiceAccountModal(): JSX.Element {
|
||||
|
||||
<DialogFooter className="create-sa-modal__footer">
|
||||
<Button
|
||||
size="md"
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
data-testid="create-sa-cancel-btn"
|
||||
testId="create-sa-cancel-btn"
|
||||
prefix={<X size={12} />}
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<AuthZButton
|
||||
size="md"
|
||||
checks={[SACreatePermission]}
|
||||
withPortal={false}
|
||||
type="submit"
|
||||
form="create-sa-form"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={!isValid}
|
||||
data-testid="create-sa-submit-btn"
|
||||
testId="create-sa-submit-btn"
|
||||
>
|
||||
Create Service Account
|
||||
</AuthZButton>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { submitImplicitly } from 'tests/submitImplicitly';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
@@ -60,7 +61,10 @@ describe('CreateServiceAccountModal', () => {
|
||||
await screen.findByTestId('create-sa-name-input');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled(),
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -72,10 +76,14 @@ describe('CreateServiceAccountModal', () => {
|
||||
const submitBtn = await screen.findByTestId('create-sa-submit-btn');
|
||||
|
||||
await user.type(nameInput, 'test');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
|
||||
await user.clear(nameInput);
|
||||
await waitFor(() => expect(submitBtn).toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
});
|
||||
|
||||
it('successful submit shows toast.success and closes modal', async () => {
|
||||
@@ -86,7 +94,9 @@ describe('CreateServiceAccountModal', () => {
|
||||
await user.type(nameInput, 'Deploy Bot');
|
||||
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -102,6 +112,40 @@ describe('CreateServiceAccountModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('pressing Enter again while creating sends one request', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let requests = 0;
|
||||
server.use(
|
||||
rest.post(SERVICE_ACCOUNTS_ENDPOINT, (_, res, ctx) => {
|
||||
requests += 1;
|
||||
return res(
|
||||
ctx.delay(100),
|
||||
ctx.status(201),
|
||||
ctx.json({ status: 'success', data: {} }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
renderModal();
|
||||
|
||||
const nameInput = await screen.findByTestId<HTMLInputElement>(
|
||||
'create-sa-name-input',
|
||||
);
|
||||
await user.type(nameInput, 'Deploy Bot');
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
|
||||
submitImplicitly(nameInput);
|
||||
await waitFor(() => expect(submitBtn).toHaveAttribute('aria-busy', 'true'));
|
||||
submitImplicitly(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
});
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
|
||||
it('shows toast.error on API error and keeps modal open', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
@@ -120,7 +164,9 @@ describe('CreateServiceAccountModal', () => {
|
||||
await user.type(nameInput, 'Dupe Bot');
|
||||
|
||||
const submitBtn = screen.getByTestId('create-sa-submit-btn');
|
||||
await waitFor(() => expect(submitBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(submitBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -180,7 +226,10 @@ describe('CreateServiceAccountModal', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
// The footer lives outside the guard: submit is gated, Cancel still works.
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toBeDisabled();
|
||||
expect(screen.getByTestId('create-sa-submit-btn')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('create-sa-cancel-btn'));
|
||||
|
||||
|
||||
@@ -4,30 +4,6 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.zoom-out-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--secondary-foreground);
|
||||
background-color: var(--secondary-background);
|
||||
border: 1px solid var(--secondary-border);
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
padding: 10px;
|
||||
height: 33px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary-background);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.timeSelection-input {
|
||||
&:hover {
|
||||
border-color: var(--l1-border) !important;
|
||||
|
||||
@@ -72,6 +72,8 @@ interface CustomTimePickerProps {
|
||||
maxTime: number;
|
||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
||||
isModalTimeSelection?: boolean;
|
||||
/** Lands on the trigger input. Not spread — the component takes no rest props. */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -95,6 +97,7 @@ function CustomTimePicker({
|
||||
minTime,
|
||||
maxTime,
|
||||
isModalTimeSelection = false,
|
||||
'data-testid': dataTestId,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [selectedTimePlaceholderValue, setSelectedTimePlaceholderValue] =
|
||||
useState('Select / Enter Time Range');
|
||||
@@ -596,6 +599,7 @@ function CustomTimePicker({
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
data-testid={dataTestId}
|
||||
autoComplete="off"
|
||||
className={cx(
|
||||
'timeSelection-input',
|
||||
@@ -650,20 +654,20 @@ function CustomTimePicker({
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
{!showLiveLogs && !isModalTimeSelection && (
|
||||
<Tooltip
|
||||
title={
|
||||
zoomOutDisabled ? 'Zoom out time range is limited to 1 month' : 'Zoom out'
|
||||
}
|
||||
>
|
||||
<Tooltip title={zoomOutDisabled ? undefined : 'Zoom out'}>
|
||||
<Button
|
||||
className="zoom-out-btn"
|
||||
disabledTooltip="Zoom out time range is limited to 1 month"
|
||||
size="md"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoomOutDisabled}
|
||||
data-testid="zoom-out-btn"
|
||||
prefix={<ZoomOut size={14} />}
|
||||
testId="zoom-out-btn"
|
||||
icon
|
||||
aria-label="Zoom out"
|
||||
variant="solid"
|
||||
color="none"
|
||||
/>
|
||||
color="secondary"
|
||||
>
|
||||
<ZoomOut size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -682,4 +686,5 @@ CustomTimePicker.defaultProps = {
|
||||
onExitLiveLogs: noop,
|
||||
showLiveLogs: false,
|
||||
showRecentlyUsed: true,
|
||||
'data-testid': undefined,
|
||||
};
|
||||
|
||||
@@ -180,6 +180,7 @@ function CustomTimePickerPopoverContent({
|
||||
type="text"
|
||||
className="time-btns"
|
||||
key={option.label + option.value}
|
||||
data-testid={`time-chip-${option.value}`}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onSelectHandler(option.label, option.value);
|
||||
@@ -259,6 +260,7 @@ function CustomTimePickerPopoverContent({
|
||||
<Button
|
||||
type="text"
|
||||
key={option.label + option.value}
|
||||
data-testid={`time-option-${option.value}`}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
@@ -164,6 +164,6 @@ describe('CustomTimePicker - zoom out button', () => {
|
||||
);
|
||||
|
||||
const zoomOutBtn = screen.getByTestId('zoom-out-btn');
|
||||
expect(zoomOutBtn).toBeDisabled();
|
||||
expect(zoomOutBtn).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,12 +27,14 @@ function DetailsHeader({
|
||||
const closeButton = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
size="sm"
|
||||
icon
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
prefix={<X size={14} />}
|
||||
></Button>
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -42,12 +42,5 @@
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,13 +308,16 @@ describe.each([
|
||||
|
||||
fireEvent.click(screen.getByText('Export'));
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeDisabled();
|
||||
expect(screen.getByTestId(testId)).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
|
||||
resolveDownload!();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId(testId)).not.toBeDisabled();
|
||||
expect(screen.getByTestId(testId)).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Popover, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -69,10 +69,15 @@ export default function DownloadOptionsMenu({
|
||||
>
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup value={exportFormat} onChange={setExportFormat}>
|
||||
<RadioGroupItem value={DownloadFormats.CSV}>csv</RadioGroupItem>
|
||||
<RadioGroupItem value={DownloadFormats.JSONL}>jsonl</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={exportFormat}
|
||||
onChange={setExportFormat}
|
||||
items={[
|
||||
{ value: DownloadFormats.CSV, label: 'csv' },
|
||||
{ value: DownloadFormats.JSONL, label: 'jsonl' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="horizontal-line" />
|
||||
@@ -80,19 +85,15 @@ export default function DownloadOptionsMenu({
|
||||
<div className="row-limit">
|
||||
<Typography.Text className="title">Number of Rows</Typography.Text>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={String(rowLimit)}
|
||||
onChange={(value): void => setRowLimit(Number(value))}
|
||||
>
|
||||
<RadioGroupItem value={String(DownloadRowCounts.TEN_K)}>
|
||||
10k
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem value={String(DownloadRowCounts.THIRTY_K)}>
|
||||
30k
|
||||
</RadioGroupItem>
|
||||
<RadioGroupItem value={String(DownloadRowCounts.FIFTY_K)}>
|
||||
50k
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
items={[
|
||||
{ value: String(DownloadRowCounts.TEN_K), label: '10k' },
|
||||
{ value: String(DownloadRowCounts.THIRTY_K), label: '30k' },
|
||||
{ value: String(DownloadRowCounts.FIFTY_K), label: '50k' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dataSource !== DataSource.TRACES && (
|
||||
@@ -101,12 +102,15 @@ export default function DownloadOptionsMenu({
|
||||
|
||||
<div className="columns-scope">
|
||||
<Typography.Text className="title">Columns</Typography.Text>
|
||||
<RadioGroup value={columnsScope} onChange={setColumnsScope}>
|
||||
<RadioGroupItem value={DownloadColumnsScopes.ALL}>All</RadioGroupItem>
|
||||
<RadioGroupItem value={DownloadColumnsScopes.SELECTED}>
|
||||
Selected
|
||||
</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={columnsScope}
|
||||
onChange={setColumnsScope}
|
||||
items={[
|
||||
{ value: DownloadColumnsScopes.ALL, label: 'All' },
|
||||
{ value: DownloadColumnsScopes.SELECTED, label: 'Selected' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -115,9 +119,9 @@ export default function DownloadOptionsMenu({
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Download size={16} />}
|
||||
size="md"
|
||||
width="100%"
|
||||
onClick={handleExport}
|
||||
className="export-button"
|
||||
disabled={isDownloading}
|
||||
loading={isDownloading}
|
||||
>
|
||||
Export
|
||||
@@ -148,13 +152,14 @@ export default function DownloadOptionsMenu({
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Download size={14} />}
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Download"
|
||||
data-testid={`periscope-btn-download-${dataSource}`}
|
||||
disabled={isDownloading}
|
||||
testId={`periscope-btn-download-${dataSource}`}
|
||||
loading={isDownloading}
|
||||
/>
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -38,18 +38,23 @@ function DeleteMemberDialog({
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button variant="solid" color="secondary" onClick={onClose}>
|
||||
<X size={12} />
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onClose}
|
||||
prefix={<X size={12} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
disabled={isDeleting}
|
||||
color="danger"
|
||||
onClick={onConfirm}
|
||||
loading={isDeleting}
|
||||
prefix={<Trash2 size={12} />}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isDeleting ? 'Processing...' : title}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +112,7 @@
|
||||
&__footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__footer-right {
|
||||
@@ -128,10 +128,6 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__tooltip-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
&__footer-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -200,6 +196,7 @@
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
padding-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
&__link-text-wrap {
|
||||
@@ -220,8 +217,4 @@
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
&__copy-btn {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +519,12 @@ function EditMemberDrawer({
|
||||
localRoles.map((roleId) => {
|
||||
const role = availableRoles.find((r) => r.id === roleId);
|
||||
return (
|
||||
<Badge key={roleId} color="vanilla">
|
||||
<Badge
|
||||
textTransform="none"
|
||||
variant="solid"
|
||||
key={roleId}
|
||||
color="secondary"
|
||||
>
|
||||
{role?.name ?? roleId}
|
||||
</Badge>
|
||||
);
|
||||
@@ -559,15 +564,15 @@ function EditMemberDrawer({
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Status</span>
|
||||
{member?.status === MemberStatus.Active ? (
|
||||
<Badge color="forest" variant="outline">
|
||||
<Badge color="success" variant="outlined">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
) : member?.status === MemberStatus.Deleted ? (
|
||||
<Badge color="cherry" variant="outline">
|
||||
<Badge color="danger" variant="outlined">
|
||||
DELETED
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="amber" variant="outline">
|
||||
<Badge color="warning" variant="outlined">
|
||||
INVITED
|
||||
</Badge>
|
||||
)}
|
||||
@@ -575,12 +580,16 @@ function EditMemberDrawer({
|
||||
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">{joinedOnLabel}</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.joinedOn)}</Badge>
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
{formatTimestamp(member?.joinedOn)}
|
||||
</Badge>
|
||||
</div>
|
||||
{!isInvited && (
|
||||
<div className="edit-member-drawer__meta-item">
|
||||
<span className="edit-member-drawer__meta-label">Last Modified</span>
|
||||
<Badge color="vanilla">{formatTimestamp(member?.updatedAt)}</Badge>
|
||||
<Badge textTransform="none" variant="solid" color="secondary">
|
||||
{formatTimestamp(member?.updatedAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -611,55 +620,59 @@ function EditMemberDrawer({
|
||||
{!isDeleted && (
|
||||
<>
|
||||
<div className="edit-member-drawer__footer-left">
|
||||
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
variant="link"
|
||||
color="destructive"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabledTooltip={getDeleteTooltip(isRootUser, isSelf)}
|
||||
size="md"
|
||||
onClick={(): void => setShowDeleteConfirm(true)}
|
||||
disabled={isRootUser || isSelf}
|
||||
variant="link"
|
||||
color="danger"
|
||||
prefix={<Trash2 size={12} />}
|
||||
>
|
||||
{isInvited ? 'Revoke Invite' : 'Delete Member'}
|
||||
</Button>
|
||||
|
||||
<div className="edit-member-drawer__footer-divider" />
|
||||
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
|
||||
<span className="edit-member-drawer__tooltip-wrapper">
|
||||
<Button
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
|
||||
variant="link"
|
||||
color="warning"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
{isGeneratingLink
|
||||
? 'Generating...'
|
||||
: isInvited
|
||||
? getInviteButtonLabel(
|
||||
isLoadingTokenStatus,
|
||||
existingToken,
|
||||
isTokenExpired,
|
||||
tokenNotFound,
|
||||
)
|
||||
: 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
disabledTooltip={ROOT_USER_TOOLTIP}
|
||||
size="md"
|
||||
onClick={handleGenerateResetLink}
|
||||
disabled={isRootUser}
|
||||
loading={isGeneratingLink || isLoadingTokenStatus}
|
||||
variant="link"
|
||||
color="warning"
|
||||
prefix={<RefreshCw size={12} />}
|
||||
>
|
||||
{isGeneratingLink
|
||||
? 'Generating...'
|
||||
: isInvited
|
||||
? getInviteButtonLabel(
|
||||
isLoadingTokenStatus,
|
||||
existingToken,
|
||||
isTokenExpired,
|
||||
tokenNotFound,
|
||||
)
|
||||
: 'Generate Password Reset Link'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="edit-member-drawer__footer-right">
|
||||
<Button variant="outlined" color="secondary" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
prefix={<X size={14} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabledTooltip={isRootUser ? ROOT_USER_TOOLTIP : 'No changes to save'}
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={!isDirty || isSaving || isRootUser}
|
||||
disabled={!isDirty || isRootUser}
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
>
|
||||
|
||||
@@ -45,11 +45,11 @@ function ResetLinkDialog({
|
||||
<span className="reset-link-dialog__link-text">{resetLink}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
onClick={onCopy}
|
||||
prefix={hasCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
className="reset-link-dialog__copy-btn"
|
||||
>
|
||||
{hasCopied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('EditMemberDrawer', () => {
|
||||
expect(screen.getByText('ACTIVE')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save member details/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('enables Save after editing name and calls updateUser on confirm', async () => {
|
||||
@@ -271,7 +271,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.type(nameInput, 'Alice Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
|
||||
await user.click(saveBtn);
|
||||
|
||||
@@ -295,7 +297,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.type(nameInput, 'Alice Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -323,7 +327,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(await screen.findByTitle('signoz-editor'));
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -349,7 +355,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.click(removeBtn);
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -505,7 +513,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.type(nameInput, 'Bob Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -541,7 +551,9 @@ describe('EditMemberDrawer', () => {
|
||||
await user.type(nameInput, 'Alice Updated');
|
||||
|
||||
const saveBtn = screen.getByRole('button', { name: /save member details/i });
|
||||
await waitFor(() => expect(saveBtn).not.toBeDisabled());
|
||||
await waitFor(() =>
|
||||
expect(saveBtn).not.toHaveAttribute('aria-disabled', 'true'),
|
||||
);
|
||||
await user.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -619,7 +631,7 @@ describe('EditMemberDrawer', () => {
|
||||
renderDrawer({ member: selfMember });
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete member/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('does not open delete confirm dialog when Delete is clicked while disabled (isSelf)', async () => {
|
||||
@@ -642,7 +654,7 @@ describe('EditMemberDrawer', () => {
|
||||
renderDrawer({ member: selfMember });
|
||||
expect(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
).not.toBeDisabled();
|
||||
).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -664,21 +676,21 @@ describe('EditMemberDrawer', () => {
|
||||
renderDrawer();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete member/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('disables Reset Link button for root user', () => {
|
||||
renderDrawer();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /generate password reset link/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('disables Save button for root user', () => {
|
||||
renderDrawer();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save member details/i }),
|
||||
).toBeDisabled();
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('does not open delete confirm dialog when Delete is clicked while disabled (root)', async () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ function ErrorModal({
|
||||
onClick={(): void => setVisible(true)}
|
||||
onKeyDown={undefined}
|
||||
>
|
||||
<Badge color="error">
|
||||
<Badge variant="solid" color="danger">
|
||||
<CircleAlert size={14} color={Color.BG_CHERRY_500} /> error
|
||||
</Badge>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button, Col, Popover, Row, Select, Space } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import axios from 'axios';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -137,16 +137,15 @@ function ExplorerCard({
|
||||
);
|
||||
};
|
||||
|
||||
const moreOptionMenu: MenuProps = {
|
||||
items: [
|
||||
{
|
||||
key: 'delete',
|
||||
label: <Typography.Text strong>Delete</Typography.Text>,
|
||||
onClick: onDeleteHandler,
|
||||
icon: <Trash2 size="md" />,
|
||||
},
|
||||
],
|
||||
};
|
||||
const moreOptionItems: DropdownItemType[] = [
|
||||
{
|
||||
type: 'item',
|
||||
value: 'delete',
|
||||
label: <Typography.Text strong>Delete</Typography.Text>,
|
||||
onClick: onDeleteHandler,
|
||||
prefix: <Trash2 size="md" />,
|
||||
},
|
||||
];
|
||||
|
||||
const saveButtonType = isQueryUpdated ? 'default' : 'primary';
|
||||
const saveButtonIcon = isQueryUpdated ? null : <Save size="md" />;
|
||||
@@ -230,9 +229,14 @@ function ExplorerCard({
|
||||
</Popover>
|
||||
<Share2 onClick={onCopyUrlHandler} size="md" />
|
||||
{viewKey && (
|
||||
<DropdownMenuSimple menu={moreOptionMenu}>
|
||||
<Dropdown
|
||||
items={moreOptionItems}
|
||||
nativeButton
|
||||
align="end"
|
||||
side="bottom"
|
||||
>
|
||||
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
|
||||
</DropdownMenuSimple>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Space>
|
||||
</OffSetCol>
|
||||
|
||||
@@ -26,8 +26,4 @@
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Download } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { RadioGroup } from '@signozhq/ui/radio-group';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
ClientExportData,
|
||||
@@ -51,36 +51,41 @@ export default function ExportMenu({
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipSimple title="Download">
|
||||
<Tooltip title="Download">
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Download"
|
||||
data-testid={`export-menu-${dataSource}`}
|
||||
disabled={isExporting}
|
||||
testId={`export-menu-${dataSource}`}
|
||||
loading={isExporting}
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="export-menu-popover">
|
||||
<div className="export-format">
|
||||
<Typography.Text className="title">FORMAT</Typography.Text>
|
||||
<RadioGroup value={exportFormat} onChange={setExportFormat}>
|
||||
<RadioGroupItem value={ExportFormat.Csv}>csv</RadioGroupItem>
|
||||
<RadioGroupItem value={ExportFormat.Jsonl}>jsonl</RadioGroupItem>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
color="primary"
|
||||
value={exportFormat}
|
||||
onChange={setExportFormat}
|
||||
items={[
|
||||
{ value: ExportFormat.Csv, label: 'csv' },
|
||||
{ value: ExportFormat.Jsonl, label: 'jsonl' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="export-button"
|
||||
width="100%"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
loading={isExporting}
|
||||
prefix={<Download size={16} />}
|
||||
>
|
||||
|
||||
@@ -80,6 +80,6 @@ describe('ExportMenu', () => {
|
||||
mockIsExporting = true;
|
||||
renderMenu();
|
||||
|
||||
expect(screen.getByTestId(TEST_ID)).toBeDisabled();
|
||||
expect(screen.getByTestId(TEST_ID)).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,9 +57,8 @@ function SortableField({
|
||||
</div>
|
||||
{!isRequired && (
|
||||
<Button
|
||||
className={cx(styles.removeBtn, 'periscope-btn')}
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
size="sm"
|
||||
onClick={(): void => onRemove(field)}
|
||||
>
|
||||
|
||||
@@ -98,11 +98,15 @@
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
|
||||
> [data-slot='button'] {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l2-background);
|
||||
|
||||
.removeBtn,
|
||||
.addBtn {
|
||||
> [data-slot='button'] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -137,14 +141,6 @@
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.removeBtn,
|
||||
.addBtn {
|
||||
padding: 4px 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -173,6 +173,7 @@ function FieldsSelectorContent({
|
||||
{hasUnsavedChanges && (
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
@@ -181,6 +182,7 @@ function FieldsSelectorContent({
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -138,7 +138,6 @@ function OtherFields({
|
||||
<span className={styles.fieldKey}>{attr.name}</span>
|
||||
{!isAtLimit && (
|
||||
<Button
|
||||
className={cx(styles.addBtn, 'periscope-btn')}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Button, Input } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { handleContactSupport } from 'container/Integrations/utils';
|
||||
@@ -102,10 +102,12 @@ function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
|
||||
return (
|
||||
<div className="feedback-modal-container">
|
||||
<div className="feedback-modal-header">
|
||||
<ToggleGroupSimple
|
||||
<ToggleGroup
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
type="single"
|
||||
value={activeTab}
|
||||
className="feedback-modal-tabs"
|
||||
onChange={setActiveTab}
|
||||
items={items}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
.header-right-section-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--spacing-8);
|
||||
padding-right: var(--spacing-8);
|
||||
}
|
||||
|
||||
.header-ai-assistant-btn-container {
|
||||
@@ -121,78 +122,26 @@
|
||||
}
|
||||
|
||||
.feedback-modal-container {
|
||||
.feedback-modal-tabs {
|
||||
width: 100%;
|
||||
.feedback-modal-tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-radio-button-wrapper {
|
||||
flex: 1;
|
||||
margin: 0px !important;
|
||||
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-radio-button-checked {
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
.tab-icon {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.feedback-modal-tab-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.tab-icon {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.feedback-tab {
|
||||
background-color: var(--danger-background);
|
||||
}
|
||||
|
||||
.bug-tab {
|
||||
background-color: var(--warning-background);
|
||||
}
|
||||
|
||||
.feature-tab {
|
||||
background-color: var(--primary-background);
|
||||
}
|
||||
.feedback-tab {
|
||||
background-color: var(--danger-background);
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list {
|
||||
.ant-tabs-tab {
|
||||
padding: 6px 16px;
|
||||
.bug-tab {
|
||||
background-color: var(--warning-background);
|
||||
}
|
||||
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
margin: 0 !important;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
&-active {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
border-bottom: none !important;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
.feature-tab {
|
||||
background-color: var(--primary-background);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Dot } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import { Popover } from 'antd';
|
||||
@@ -113,24 +113,26 @@ function HeaderRightSection({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className="noz-wave"
|
||||
onClick={handleOpenAIAssistant}
|
||||
aria-label={
|
||||
showHeaderPendingBadge
|
||||
? pendingUserInputCount === 1
|
||||
? 'Open Noz, 1 action needs your response'
|
||||
: `Open Noz, ${pendingUserInputCount} actions need your response`
|
||||
: 'Open Noz'
|
||||
}
|
||||
prefix={<Noz size={20} />}
|
||||
>
|
||||
<Typography.Text>Noz</Typography.Text>
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
<span className="noz-wave">
|
||||
<Tooltip title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onClick={handleOpenAIAssistant}
|
||||
aria-label={
|
||||
showHeaderPendingBadge
|
||||
? pendingUserInputCount === 1
|
||||
? 'Open Noz, 1 action needs your response'
|
||||
: `Open Noz, ${pendingUserInputCount} actions need your response`
|
||||
: 'Open Noz'
|
||||
}
|
||||
prefix={<Noz size={20} />}
|
||||
>
|
||||
<Typography.Text>Noz</Typography.Text>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,13 +149,15 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenFeedbackModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="share-feedback-btn"
|
||||
color="primary"
|
||||
variant="link"
|
||||
size="md"
|
||||
icon
|
||||
aria-label="Feedback"
|
||||
prefix={<SquarePen size={14} />}
|
||||
onClick={handleOpenFeedbackModal}
|
||||
/>
|
||||
>
|
||||
<SquarePen size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
@@ -170,16 +174,19 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenAnnouncementsModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="primary"
|
||||
variant="link"
|
||||
size="md"
|
||||
icon
|
||||
aria-label="Announcements"
|
||||
prefix={<Inbox size={14} />}
|
||||
onClick={(): void => {
|
||||
logEvent('Announcements: Clicked', {
|
||||
page: location.pathname,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Inbox size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
@@ -196,12 +203,15 @@ function HeaderRightSection({
|
||||
onOpenChange={handleOpenShareURLModalChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="primary"
|
||||
variant="link"
|
||||
size="md"
|
||||
icon
|
||||
aria-label="Share"
|
||||
prefix={<Globe size={14} />}
|
||||
onClick={handleOpenShareURLModal}
|
||||
/>
|
||||
>
|
||||
<Globe size={14} />
|
||||
</Button>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -149,6 +149,9 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
<Info size={14} color={Color.BG_AMBER_600} />
|
||||
)}
|
||||
<Switch
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
disabledTooltip="Please select / enter valid relative time to toggle."
|
||||
value={enableAbsoluteTime}
|
||||
disabled={!isValidateRelativeTime}
|
||||
onChange={(): void => {
|
||||
@@ -173,6 +176,8 @@ function ShareURLModal({ extraOption }: ShareURLModalProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
<div className="absolute-relative-time-toggler">
|
||||
<Switch
|
||||
color="primary"
|
||||
textPlacement="right"
|
||||
value={enableExtraOption}
|
||||
onChange={(): void => setEnableExtraOption((prev) => !prev)}
|
||||
/>
|
||||
|
||||
@@ -69,23 +69,23 @@ describe('FeedbackModal', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FeedbackModal onClose={mockOnClose} />);
|
||||
|
||||
// Initially, feedback radio should be active
|
||||
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
|
||||
expect(feedbackRadio).toBeChecked();
|
||||
// Initially, feedback button should be active
|
||||
const feedbackButton = screen.getByRole('button', { name: 'Feedback' });
|
||||
expect(feedbackButton).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const bugTab = screen.getByText('Report a bug');
|
||||
await user.click(bugTab);
|
||||
|
||||
// Bug radio should now be active
|
||||
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
|
||||
expect(bugRadio).toBeChecked();
|
||||
// Bug button should now be active
|
||||
const bugButton = screen.getByRole('button', { name: 'Report a bug' });
|
||||
expect(bugButton).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const featureTab = screen.getByText('Feature request');
|
||||
await user.click(featureTab);
|
||||
|
||||
// Feature radio should now be active
|
||||
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
|
||||
expect(featureRadio).toBeChecked();
|
||||
// Feature button should now be active
|
||||
const featureButton = screen.getByRole('button', { name: 'Feature request' });
|
||||
expect(featureButton).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('should update feedback text when typing in textarea', async () => {
|
||||
@@ -133,9 +133,9 @@ describe('FeedbackModal', () => {
|
||||
const bugTab = screen.getByText('Report a bug');
|
||||
await user.click(bugTab);
|
||||
|
||||
// Verify bug report radio is now active
|
||||
const bugRadio = screen.getByRole('radio', { name: 'Report a bug' });
|
||||
expect(bugRadio).toBeChecked();
|
||||
// Verify bug report button is now active
|
||||
const bugButton = screen.getByRole('button', { name: 'Report a bug' });
|
||||
expect(bugButton).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const submitButton = screen.getByRole('button', { name: /submit/i });
|
||||
@@ -166,9 +166,9 @@ describe('FeedbackModal', () => {
|
||||
const featureTab = screen.getByText('Feature request');
|
||||
await user.click(featureTab);
|
||||
|
||||
// Verify feature request radio is now active
|
||||
const featureRadio = screen.getByRole('radio', { name: 'Feature request' });
|
||||
expect(featureRadio).toBeChecked();
|
||||
// Verify feature request button is now active
|
||||
const featureButton = screen.getByRole('button', { name: 'Feature request' });
|
||||
expect(featureButton).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write your feedback here...');
|
||||
const submitButton = screen.getByRole('button', { name: /submit/i });
|
||||
@@ -262,8 +262,8 @@ describe('FeedbackModal', () => {
|
||||
);
|
||||
expect(newTextArea).toHaveValue(''); // Should be empty
|
||||
|
||||
// Verify active radio is reset to default (Feedback radio)
|
||||
const feedbackRadio = screen.getByRole('radio', { name: 'Feedback' });
|
||||
expect(feedbackRadio).toBeChecked();
|
||||
// Verify active button is reset to default (Feedback button)
|
||||
const feedbackButton = screen.getByRole('button', { name: 'Feedback' });
|
||||
expect(feedbackButton).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('ShareURLModal', () => {
|
||||
expect(
|
||||
screen.getByText('Please select / enter valid relative time to toggle.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('switch')).toBeDisabled();
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('should process URL with absolute time for non-custom time', async () => {
|
||||
|
||||
@@ -1,44 +1,32 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
|
||||
|
||||
type BadgeColor =
|
||||
| 'vanilla'
|
||||
| 'robin'
|
||||
| 'forest'
|
||||
| 'amber'
|
||||
| 'sienna'
|
||||
| 'cherry'
|
||||
| 'sakura'
|
||||
| 'aqua';
|
||||
function getStatusCodeColor(statusCode: number): BadgeColorType {
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return 'success';
|
||||
}
|
||||
if (statusCode >= 300 && statusCode < 400) {
|
||||
return 'primary';
|
||||
}
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
return 'warning';
|
||||
}
|
||||
if (statusCode >= 500) {
|
||||
return 'danger';
|
||||
}
|
||||
if (statusCode >= 100 && statusCode < 200) {
|
||||
return 'secondary';
|
||||
}
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
interface HttpStatusBadgeProps {
|
||||
statusCode: string | number;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getStatusCodeColor(statusCode: number): BadgeColor {
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return 'forest'; // Success - green
|
||||
}
|
||||
if (statusCode >= 300 && statusCode < 400) {
|
||||
return 'robin'; // Redirect - blue
|
||||
}
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
return 'amber'; // Client error - amber
|
||||
}
|
||||
if (statusCode >= 500) {
|
||||
return 'cherry'; // Server error - red
|
||||
}
|
||||
if (statusCode >= 100 && statusCode < 200) {
|
||||
return 'vanilla'; // Informational - neutral
|
||||
}
|
||||
return 'robin'; // Default fallback
|
||||
}
|
||||
|
||||
function HttpStatusBadge({
|
||||
statusCode,
|
||||
testId,
|
||||
className,
|
||||
}: HttpStatusBadgeProps): JSX.Element | null {
|
||||
const numericStatusCode = Number(statusCode);
|
||||
|
||||
@@ -49,12 +37,7 @@ function HttpStatusBadge({
|
||||
const color = getStatusCodeColor(numericStatusCode);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={color}
|
||||
variant="outline"
|
||||
data-testid={testId}
|
||||
className={className}
|
||||
>
|
||||
<Badge color={color} variant="outlined" testId={testId}>
|
||||
{statusCode}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -119,11 +119,13 @@ function InviteMembers({
|
||||
<div className={styles.cellAction}>
|
||||
{canRemoveRow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="md"
|
||||
variant="solid"
|
||||
color="danger"
|
||||
onClick={(): void => removeRow(row.id)}
|
||||
aria-label="Remove row"
|
||||
data-testid={`invite-remove-${row.id}`}
|
||||
testId={`invite-remove-${row.id}`}
|
||||
icon
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
@@ -136,11 +138,12 @@ function InviteMembers({
|
||||
{showAddButton && (
|
||||
<div className={styles.addRow}>
|
||||
<Button
|
||||
size="md"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={12} />}
|
||||
onClick={addRow}
|
||||
data-testid="invite-add-row"
|
||||
testId="invite-add-row"
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
|
||||
@@ -161,37 +161,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.views-tabs {
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.view-title {
|
||||
display: flex;
|
||||
gap: var(--margin-2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xs);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
> button {
|
||||
border: 1px solid var(--l1-border);
|
||||
width: 114px;
|
||||
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--l1-border);
|
||||
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
.view-title {
|
||||
display: flex;
|
||||
gap: var(--margin-2);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-xs);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@@ -242,42 +219,4 @@
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.log-arrow-btn {
|
||||
padding: 0;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
border: 1px solid var(--l1-border);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.log-arrow-btn-up,
|
||||
.log-arrow-btn-down {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.log-arrow-btn:active,
|
||||
.log-arrow-btn:focus {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.log-arrow-btn[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
background: var(--l1-background);
|
||||
color: var(--l3-foreground);
|
||||
|
||||
.log-arrow-btn:hover:not([disabled]) {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,6 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tooltipContent {
|
||||
--tooltip-z-index: 2100;
|
||||
}
|
||||
|
||||
.dropdownContent {
|
||||
--dropdown-menu-content-z-index: 2100;
|
||||
}
|
||||
|
||||
.leftSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { aggregateAttributesResourcesToString } from 'container/LogDetailedView/utils';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
@@ -23,8 +23,6 @@ import { useCopyToClipboard } from 'react-use';
|
||||
|
||||
import styles from './LogDetailsHeader.module.scss';
|
||||
|
||||
const TOOLTIP_CONTENT_PROPS = { className: styles.tooltipContent };
|
||||
|
||||
interface LogDetailsHeaderProps {
|
||||
log: ILog;
|
||||
onNavigatePrev: () => void;
|
||||
@@ -53,17 +51,19 @@ function LogDetailsHeader({
|
||||
toast.success('Copied to clipboard', { position: 'bottom-right' });
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
const menuItems: DropdownItemType[] = [
|
||||
{
|
||||
key: 'copy-log',
|
||||
type: 'item',
|
||||
value: 'copy-log',
|
||||
label: 'Copy log',
|
||||
icon: <Copy size={14} />,
|
||||
prefix: <Copy size={14} />,
|
||||
onClick: handleCopyLog,
|
||||
},
|
||||
{
|
||||
key: 'copy-link',
|
||||
type: 'item',
|
||||
value: 'copy-link',
|
||||
label: 'Copy link to log',
|
||||
icon: <Link size={14} />,
|
||||
prefix: <Link size={14} />,
|
||||
onClick: (): void => onLogCopy(),
|
||||
},
|
||||
];
|
||||
@@ -91,6 +91,7 @@ function LogDetailsHeader({
|
||||
<div className={styles.actions}>
|
||||
{showOpenInExplorer && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<Compass size={16} />}
|
||||
@@ -100,51 +101,57 @@ function LogDetailsHeader({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
align="end"
|
||||
className={styles.dropdownContent}
|
||||
onClick={(e: MouseEvent): void => e.stopPropagation()}
|
||||
>
|
||||
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
|
||||
<Button
|
||||
size="md"
|
||||
variant="link"
|
||||
color="secondary"
|
||||
prefix={<Ellipsis size={16} />}
|
||||
data-testid="log-details-header-menu"
|
||||
/>
|
||||
icon
|
||||
aria-label="Log actions"
|
||||
testId="log-details-header-menu"
|
||||
onClick={(e: MouseEvent): void => e.stopPropagation()}
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
<div className={styles.arrows}>
|
||||
<TooltipSimple
|
||||
title="Move to previous log"
|
||||
<Tooltip
|
||||
title={isPrevDisabled ? undefined : 'Move to previous log'}
|
||||
side="top"
|
||||
open={isPrevDisabled ? false : undefined}
|
||||
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
|
||||
>
|
||||
<Button
|
||||
disabledTooltip="No previous log"
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronUp size={14} />}
|
||||
icon
|
||||
aria-label="Move to previous log"
|
||||
disabled={isPrevDisabled}
|
||||
onClick={onNavigatePrev}
|
||||
data-testid="log-details-header-prev"
|
||||
/>
|
||||
</TooltipSimple>
|
||||
<TooltipSimple
|
||||
title="Move to next log"
|
||||
testId="log-details-header-prev"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={isNextDisabled ? undefined : 'Move to next log'}
|
||||
side="top"
|
||||
open={isNextDisabled ? false : undefined}
|
||||
tooltipContentProps={TOOLTIP_CONTENT_PROPS}
|
||||
>
|
||||
<Button
|
||||
disabledTooltip="No next log"
|
||||
size="md"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<ChevronDown size={14} />}
|
||||
icon
|
||||
aria-label="Move to next log"
|
||||
disabled={isNextDisabled}
|
||||
onClick={onNavigateNext}
|
||||
data-testid="log-details-header-next"
|
||||
/>
|
||||
</TooltipSimple>
|
||||
testId="log-details-header-next"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.valueBadge {
|
||||
--badge-font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Truncating text inside a badge
|
||||
.badgeText {
|
||||
min-width: 0;
|
||||
@@ -29,11 +22,14 @@
|
||||
|
||||
.serviceDot {
|
||||
width: 6px;
|
||||
min-width: 6px;
|
||||
height: 6px;
|
||||
min-height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.traceLink {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Badge, BadgeColor } from '@signozhq/ui/badge';
|
||||
import { Badge, type BadgeColorType } from '@signozhq/ui/badge';
|
||||
import { LogType } from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -8,13 +8,13 @@ import styles from './LogHighlights.module.scss';
|
||||
import TraceIdField from './TraceIdField';
|
||||
|
||||
// Severity badge color mirrors the LogStateIndicator bar
|
||||
const SEVERITY_COLOR: Record<string, BadgeColor> = {
|
||||
[LogType.TRACE]: 'forest',
|
||||
[LogType.DEBUG]: 'aqua',
|
||||
[LogType.INFO]: 'robin',
|
||||
[LogType.WARN]: 'amber',
|
||||
[LogType.ERROR]: 'cherry',
|
||||
[LogType.FATAL]: 'sakura',
|
||||
const SEVERITY_COLOR: Record<string, BadgeColorType> = {
|
||||
[LogType.TRACE]: 'success',
|
||||
[LogType.DEBUG]: 'info',
|
||||
[LogType.INFO]: 'primary',
|
||||
[LogType.WARN]: 'warning',
|
||||
[LogType.ERROR]: 'danger',
|
||||
[LogType.FATAL]: 'highlight-danger',
|
||||
};
|
||||
|
||||
export interface LogHighlightConfig {
|
||||
@@ -32,9 +32,13 @@ const getAttr = (log: ILog, key: string): string =>
|
||||
|
||||
const valueBadge = (
|
||||
value: string,
|
||||
options?: { prefix?: ReactNode; color?: BadgeColor },
|
||||
options?: { prefix?: ReactNode; color?: BadgeColorType },
|
||||
): ReactNode => (
|
||||
<Badge color={options?.color ?? 'vanilla'} className={styles.valueBadge}>
|
||||
<Badge
|
||||
variant="solid"
|
||||
color={options?.color ?? 'secondary'}
|
||||
textTransform="none"
|
||||
>
|
||||
{options?.prefix}
|
||||
<span className={styles.badgeText} title={value}>
|
||||
{value}
|
||||
|
||||
@@ -129,6 +129,18 @@ describe('LogDetail drawer — header', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the drawer open when a ⋯ menu item is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onClose = jest.fn();
|
||||
|
||||
renderDrawer({ onClose });
|
||||
|
||||
await user.click(screen.getByTestId('log-details-header-menu'));
|
||||
await user.click(await screen.findByText('Copy log'));
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows "Open in Explorer" when a handleOpenInExplorer handler is provided', () => {
|
||||
renderDrawer({ handleOpenInExplorer: jest.fn() });
|
||||
|
||||
@@ -217,8 +229,14 @@ describe('LogDetail drawer — header', () => {
|
||||
// Active log is the first one.
|
||||
renderDrawer({ log: logs[0], logs, onNavigateLog });
|
||||
|
||||
expect(screen.getByTestId('log-details-header-prev')).toBeDisabled();
|
||||
expect(screen.getByTestId('log-details-header-next')).toBeEnabled();
|
||||
expect(screen.getByTestId('log-details-header-prev')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByTestId('log-details-header-next')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('log-details-header-next'));
|
||||
expect(onNavigateLog).toHaveBeenLastCalledWith(logs[1]);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { ToggleGroup } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
@@ -87,7 +87,8 @@ function LogDetailInner({
|
||||
target.closest('.cm-tooltip-autocomplete') ||
|
||||
target.closest('.drawer-popover') ||
|
||||
target.closest('.query-status-popover') ||
|
||||
target.closest('[data-radix-popper-content-wrapper]')
|
||||
target.closest('[data-radix-popper-content-wrapper]') ||
|
||||
target.closest('[data-base-ui-portal]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -317,9 +318,12 @@ function LogDetailInner({
|
||||
<div className="log-detail-drawer__section-divider" />
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
<ToggleGroup
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
testId="log-detail-views-tabs"
|
||||
onChange={handleModeChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
@@ -365,9 +369,12 @@ function LogDetailInner({
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Filter size="lg" />}
|
||||
icon
|
||||
aria-label="Show Filters"
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
>
|
||||
<Filter size="lg" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import {
|
||||
Bold,
|
||||
CodeXml,
|
||||
@@ -11,16 +11,17 @@ import {
|
||||
Type,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { READ_ONLY_TOOLTIP } from './constants';
|
||||
import InsertVariableMenu from './InsertVariableMenu';
|
||||
import MarkdownHelp from './MarkdownHelp';
|
||||
import type { EditorCommand, EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
const COMMAND_ICONS: Record<string, ReactNode> = {
|
||||
const COMMAND_ICONS: Record<string, ReactElement> = {
|
||||
heading: <Heading size={14} />,
|
||||
bold: <Bold size={14} />,
|
||||
italic: <Italic size={14} />,
|
||||
@@ -61,20 +62,22 @@ function EditorToolbar({
|
||||
<span className={styles.toolbarDivider} />
|
||||
<div className={styles.commands}>
|
||||
{commands.map((command) => (
|
||||
<TooltipSimple key={command.id} title={command.label}>
|
||||
<Tooltip key={command.id} title={disabled ? undefined : command.label}>
|
||||
<Button
|
||||
disabledTooltip={READ_ONLY_TOOLTIP}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
size="sm"
|
||||
icon
|
||||
disabled={disabled}
|
||||
aria-label={command.label}
|
||||
data-testid={`markdown-command-${command.id}`}
|
||||
testId={`markdown-command-${command.id}`}
|
||||
onClick={(): void => onRunCommand(command)}
|
||||
>
|
||||
{COMMAND_ICONS[command.id]}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.toolbarEnd}>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
|
||||
import { ChevronDown, DollarSign } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Dropdown, type DropdownActionItemType } from '@signozhq/ui/dropdown';
|
||||
|
||||
import { READ_ONLY_TOOLTIP } from './constants';
|
||||
import type { EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
@@ -17,9 +18,10 @@ interface InsertVariableMenuProps {
|
||||
function toMenuItems(
|
||||
variables: EditorVariable[],
|
||||
onSelect: (name: string) => void,
|
||||
): MenuItem[] {
|
||||
): DropdownActionItemType[] {
|
||||
return variables.map((variable) => ({
|
||||
key: variable.name,
|
||||
type: 'item',
|
||||
value: variable.name,
|
||||
label: (
|
||||
<span
|
||||
className={styles.variableRow}
|
||||
@@ -60,15 +62,17 @@ function InsertVariableMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple
|
||||
className={styles.variableMenu}
|
||||
menu={{
|
||||
items,
|
||||
search: {
|
||||
placeholder: 'Search variables',
|
||||
searchIcon: <Search size={14} />,
|
||||
onSearchChange: setSearch,
|
||||
},
|
||||
<Dropdown
|
||||
items={items}
|
||||
nativeButton
|
||||
align="end"
|
||||
side="bottom"
|
||||
contentMaxWidth={320}
|
||||
disabled={disabled}
|
||||
disabledTooltip={READ_ONLY_TOOLTIP}
|
||||
searchInputProps={{
|
||||
placeholder: 'Search variables',
|
||||
onChange: setSearch,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -76,15 +80,13 @@ function InsertVariableMenu({
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
|
||||
suffix={<ChevronDown size={14} />}
|
||||
className={styles.insertVariable}
|
||||
data-testid="markdown-insert-variable"
|
||||
testId="markdown-insert-variable"
|
||||
>
|
||||
Insert variable
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,27 +74,10 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.insertVariable {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insertVariableIcon {
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
|
||||
// The ui library's dropdown assumes a global border-box reset this app doesn't
|
||||
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
|
||||
// `width: 100%` + padding, so in the portal they lay out content-box and
|
||||
// overflow the popup by the padding — clipping the flush-right badge.
|
||||
.variableMenu,
|
||||
.variableMenu * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.variableMenu {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
// Shrinkable, so a clamped popup truncates the name instead of clipping the
|
||||
// badge at the content's `overflow: hidden` edge.
|
||||
.variableRow {
|
||||
|
||||
@@ -15,9 +15,10 @@ function MarkdownHelp(): JSX.Element {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label="Markdown syntax help"
|
||||
data-testid="markdown-help-trigger"
|
||||
testId="markdown-help-trigger"
|
||||
>
|
||||
<CircleHelp size={14} />
|
||||
</Button>
|
||||
|
||||
@@ -256,8 +256,14 @@ describe('MarkdownEditor', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
|
||||
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
|
||||
expect(screen.getByTestId('markdown-command-bold')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByTestId('markdown-insert-variable')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the insert-variable control when none are available', () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// The body is persisted inline in the dashboard JSON, so its length is capped.
|
||||
export const MARKDOWN_MAX_LENGTH = 16000;
|
||||
|
||||
export const READ_ONLY_TOOLTIP = 'The editor is read-only';
|
||||
|
||||
/** The canonical syntax; the renderer resolves the other three too. */
|
||||
export const formatVariableToken = (name: string): string => `$${name}`;
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
line-height: 100%;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,14 +55,14 @@ function NameEmailCell({
|
||||
function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
|
||||
if (status === MemberStatus.Active) {
|
||||
return (
|
||||
<Badge color="forest" variant="outline">
|
||||
<Badge color="success" variant="outlined">
|
||||
ACTIVE
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (status === MemberStatus.Deleted) {
|
||||
return (
|
||||
<Badge color="cherry" variant="outline">
|
||||
<Badge color="danger" variant="outlined">
|
||||
DELETED
|
||||
</Badge>
|
||||
);
|
||||
@@ -70,13 +70,17 @@ function StatusBadge({ status }: { status: MemberRow['status'] }): JSX.Element {
|
||||
|
||||
if (status === MemberStatus.Invited) {
|
||||
return (
|
||||
<Badge color="amber" variant="outline">
|
||||
<Badge color="warning" variant="outlined">
|
||||
INVITED
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return <Badge color="vanilla">⎯</Badge>;
|
||||
return (
|
||||
<Badge variant="solid" color="secondary">
|
||||
⎯
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersEmptyState({
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip/TextToolTip';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -764,9 +764,14 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
width="100%"
|
||||
onChange={(): void => {
|
||||
handleItemSelection('checkbox');
|
||||
setActiveChipIndex(-1);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
>
|
||||
<div className="option-content">
|
||||
<span ref={textRef} className="option-label-text">
|
||||
@@ -1604,7 +1609,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
|
||||
<Checkbox value={allOptionsSelected} className="option-checkbox">
|
||||
<Checkbox color="primary" value={allOptionsSelected} width="100%">
|
||||
<div className="option-content">
|
||||
<div className="all-option-text">ALL</div>
|
||||
</div>
|
||||
@@ -1983,13 +1988,9 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
// `label` arrives already cut to maxTagTextLength, so the reveal reads the
|
||||
// option's own text (falling back to the raw value for freeform tags).
|
||||
return (
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
delayDuration={300}
|
||||
title={findOptionLabelText(options, value)}
|
||||
>
|
||||
<Tooltip side="top" title={findOptionLabelText(options, value)}>
|
||||
{tag}
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2025,56 +2026,51 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
return (
|
||||
// Self-provided so the per-tag tooltips work wherever this select is rendered,
|
||||
// without every consumer having to sit under an app-level provider.
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className={cx('custom-multiselect-wrapper', {
|
||||
'all-selected': allOptionShown || isAllSelected,
|
||||
<div
|
||||
className={cx('custom-multiselect-wrapper', {
|
||||
'all-selected': allOptionShown || isAllSelected,
|
||||
})}
|
||||
>
|
||||
{(allOptionShown || isAllSelected) && !searchText && (
|
||||
<div className="all-text">ALL</div>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
})}
|
||||
>
|
||||
{(allOptionShown || isAllSelected) && !searchText && (
|
||||
<div className="all-text">ALL</div>
|
||||
)}
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-multiselect', className, {
|
||||
'has-selection': selectedChips.length > 0 && !isAllSelected,
|
||||
'is-all-selected': isAllSelected,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={(newValue): void => {
|
||||
handleInternalChange(newValue, false);
|
||||
}}
|
||||
onClear={onClearHandler}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx(
|
||||
'custom-multiselect-dropdown-container',
|
||||
popupClassName,
|
||||
)}
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
tagRender={tagRender as any}
|
||||
placement={placement}
|
||||
listHeight={300}
|
||||
searchValue={searchText}
|
||||
maxTagTextLength={maxTagTextLength}
|
||||
maxTagCount={isAllSelected ? undefined : maxTagCount}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
placeholder={placeholder}
|
||||
mode="multiple"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={displayValue}
|
||||
onChange={(newValue): void => {
|
||||
handleInternalChange(newValue, false);
|
||||
}}
|
||||
onClear={onClearHandler}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={dropdownMatchSelectWidth}
|
||||
allowClear={allowClear}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
tagRender={tagRender as any}
|
||||
placement={placement}
|
||||
listHeight={300}
|
||||
searchValue={searchText}
|
||||
maxTagTextLength={maxTagTextLength}
|
||||
maxTagCount={isAllSelected ? undefined : maxTagCount}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -740,36 +739,33 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
// Self-provided so label tooltips work wherever this select is rendered.
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// Above the antd select popup it is shown from.
|
||||
.tooltipContent {
|
||||
--tooltip-z-index: 2100;
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { ReactElement, RefObject, useRef, useState } from 'react';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './TruncatedTooltip.module.scss';
|
||||
import {
|
||||
cloneElement,
|
||||
FocusEvent,
|
||||
PointerEvent,
|
||||
ReactElement,
|
||||
RefObject,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Tooltip, TooltipProps } from '@signozhq/ui/tooltip';
|
||||
|
||||
interface TruncatedTooltipProps {
|
||||
title: string;
|
||||
side?: TooltipSimpleProps['side'];
|
||||
side?: TooltipProps['side'];
|
||||
/** Renders the trigger; attach the ref to the element that may truncate. */
|
||||
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
|
||||
}
|
||||
@@ -16,25 +22,28 @@ function TruncatedTooltip({
|
||||
children,
|
||||
}: TruncatedTooltipProps): JSX.Element {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
const measure = (): void => {
|
||||
const el = textRef.current;
|
||||
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
|
||||
setTruncated(!!el && el.scrollWidth > el.clientWidth);
|
||||
};
|
||||
|
||||
const trigger = children(textRef);
|
||||
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={title}
|
||||
side={side}
|
||||
arrow
|
||||
delayDuration={300}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
tooltipContentProps={{ className: styles.tooltipContent }}
|
||||
>
|
||||
{children(textRef)}
|
||||
</TooltipSimple>
|
||||
<Tooltip title={truncated ? title : undefined} side={side}>
|
||||
{cloneElement(trigger, {
|
||||
onPointerEnter: (event: PointerEvent<HTMLElement>): void => {
|
||||
measure();
|
||||
trigger.props.onPointerEnter?.(event);
|
||||
},
|
||||
onFocus: (event: FocusEvent<HTMLElement>): void => {
|
||||
measure();
|
||||
trigger.props.onFocus?.(event);
|
||||
},
|
||||
})}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import CustomMultiSelect from '../CustomMultiSelect';
|
||||
|
||||
@@ -14,15 +13,13 @@ const SELECTED = ['checkout-service-prod', 'payments-service-prod'];
|
||||
|
||||
function renderSelect(): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CustomMultiSelect
|
||||
options={OPTIONS}
|
||||
value={SELECTED}
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
<CustomMultiSelect
|
||||
options={OPTIONS}
|
||||
value={SELECTED}
|
||||
maxTagCount={1}
|
||||
maxTagTextLength={10}
|
||||
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import TruncatedTooltip from '../TruncatedTooltip';
|
||||
|
||||
@@ -15,15 +14,13 @@ function renderRow(scrollWidth: number, clientWidth: number): void {
|
||||
.mockReturnValue(clientWidth);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<TruncatedTooltip title={LABEL}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div data-testid="row">
|
||||
<span ref={textRef}>{LABEL}</span>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
</TooltipProvider>,
|
||||
<TruncatedTooltip title={LABEL}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div data-testid="row">
|
||||
<span ref={textRef}>{LABEL}</span>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -532,114 +532,95 @@ $custom-border-color: #2c3044;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.option-checkbox {
|
||||
.all-option-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// The checkbox button is the only pointer target on the left; the label
|
||||
// still toggles on click but keeps a default cursor.
|
||||
> button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.option-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
// @signozhq/ui Checkbox renders children inside a <label> that is
|
||||
// content-sized by default. Make it fill the row (min-width: 0 lets it
|
||||
// shrink) so the option text below can truncate instead of overflowing.
|
||||
> label {
|
||||
.option-label-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.all-option-text {
|
||||
.option-badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: $custom-border-color;
|
||||
color: var(--l2-foreground);
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// "Only"/"All" is the primary action — a filled pill that reads as a
|
||||
// button; "Toggle" is a secondary hint in plain text. Sized to the row's
|
||||
// resting height so revealing them on hover never shifts it.
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
padding: 4px 8px;
|
||||
// Black interior + a visible border so the pill stands out clearly
|
||||
// against the near-black row when revealed on hover.
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bg-ink-500, #0b0c0e);
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Toggle appears over the checkbox area; "Only/All" takes over the row
|
||||
// content and hides Toggle there (higher specificity wins).
|
||||
&:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option-content {
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.option-content:hover {
|
||||
.only-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.option-label-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background-color: $custom-border-color;
|
||||
color: var(--l2-foreground);
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// "Only"/"All" is the primary action — a filled pill that reads as a
|
||||
// button; "Toggle" is a secondary hint in plain text. Sized to the row's
|
||||
// resting height so revealing them on hover never shifts it.
|
||||
.only-btn,
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 18px;
|
||||
min-height: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.only-btn {
|
||||
padding: 4px 8px;
|
||||
// Black interior + a visible border so the pill stands out clearly
|
||||
// against the near-black row when revealed on hover.
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bg-ink-500, #0b0c0e);
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Toggle appears over the checkbox area; "Only/All" takes over the row
|
||||
// content and hides Toggle there (higher specificity wins).
|
||||
&:hover {
|
||||
.toggle-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.option-content:hover {
|
||||
.only-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
.option-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user