Merge branch 'main' into feature/deployment-status-badge
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m7s
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m48s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Failing after 2m19s
Some checks failed
CI / cleanup-branch (pull_request) Has been skipped
CI / build (pull_request) Successful in 2m7s
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m6s
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
CI / docker (push) Successful in 1m48s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Failing after 2m19s
This commit is contained in:
File diff suppressed because one or more lines are too long
200
ui/src/api/schema.d.ts
vendored
200
ui/src/api/schema.d.ts
vendored
@@ -1037,6 +1037,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/server-metrics/query": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Generic time-series query
|
||||
* @description Returns bucketed series for a single metric_name. Supports aggregation (avg/sum/max/min/latest), group-by-tag, filter-by-tag, counter delta mode, and a derived 'mean' statistic for timers.
|
||||
*/
|
||||
post: operations["query"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/roles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1556,7 +1576,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Find the latest diagram for this app's route in this environment
|
||||
* @description Resolves agents in this env for this app, then looks up the latest diagram for the route they reported. Env scope prevents a dev route from returning a prod diagram.
|
||||
* @description Returns the most recently stored diagram for (app, env, route). Independent of the agent registry, so routes removed from the current app version still resolve.
|
||||
*/
|
||||
get: operations["findByAppAndRoute"];
|
||||
put?: never;
|
||||
@@ -1912,6 +1932,46 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/server-metrics/instances": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List server_instance_id values observed in the window
|
||||
* @description Returns first/last seen timestamps — use to partition counter-delta computations.
|
||||
*/
|
||||
get: operations["instances"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/server-metrics/catalog": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List metric names observed in the window
|
||||
* @description For each metric_name, returns metric_type, the set of statistics emitted, and the union of tag keys.
|
||||
*/
|
||||
get: operations["catalog"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/rbac/stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2209,6 +2269,17 @@ export interface components {
|
||||
[key: string]: number;
|
||||
};
|
||||
sensitiveKeys?: string[];
|
||||
/** Format: int32 */
|
||||
exportBatchSize?: number;
|
||||
/** Format: int32 */
|
||||
exportQueueSize?: number;
|
||||
/** Format: int64 */
|
||||
exportFlushIntervalMs?: number;
|
||||
exportOverflowMode?: string;
|
||||
/** Format: int64 */
|
||||
exportBlockTimeoutMs?: number;
|
||||
/** Format: int32 */
|
||||
flushRecordThreshold?: number;
|
||||
};
|
||||
TapDefinition: {
|
||||
tapId?: string;
|
||||
@@ -2630,6 +2701,12 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
createdAt?: string;
|
||||
};
|
||||
AttributeFilter: {
|
||||
key?: string;
|
||||
value?: string;
|
||||
keyOnly?: boolean;
|
||||
wildcard?: boolean;
|
||||
};
|
||||
SearchRequest: {
|
||||
status?: string;
|
||||
/** Format: date-time */
|
||||
@@ -2658,6 +2735,7 @@ export interface components {
|
||||
sortDir?: string;
|
||||
afterExecutionId?: string;
|
||||
environment?: string;
|
||||
attributeFilters?: components["schemas"]["AttributeFilter"][];
|
||||
};
|
||||
ExecutionSummary: {
|
||||
executionId: string;
|
||||
@@ -2967,6 +3045,42 @@ export interface components {
|
||||
SetPasswordRequest: {
|
||||
password?: string;
|
||||
};
|
||||
QueryBody: {
|
||||
metric?: string;
|
||||
statistic?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** Format: int32 */
|
||||
stepSeconds?: number;
|
||||
groupByTags?: string[];
|
||||
filterTags?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
aggregation?: string;
|
||||
mode?: string;
|
||||
serverInstanceIds?: string[];
|
||||
};
|
||||
ServerMetricPoint: {
|
||||
/** Format: date-time */
|
||||
t?: string;
|
||||
/** Format: double */
|
||||
v?: number;
|
||||
};
|
||||
ServerMetricQueryResponse: {
|
||||
metric?: string;
|
||||
statistic?: string;
|
||||
aggregation?: string;
|
||||
mode?: string;
|
||||
/** Format: int32 */
|
||||
stepSeconds?: number;
|
||||
series?: components["schemas"]["ServerMetricSeries"][];
|
||||
};
|
||||
ServerMetricSeries: {
|
||||
tags?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
points?: components["schemas"]["ServerMetricPoint"][];
|
||||
};
|
||||
CreateRoleRequest: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -3491,6 +3605,19 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
avgDurationMs?: number;
|
||||
};
|
||||
ServerInstanceInfo: {
|
||||
serverInstanceId?: string;
|
||||
/** Format: date-time */
|
||||
firstSeen?: string;
|
||||
/** Format: date-time */
|
||||
lastSeen?: string;
|
||||
};
|
||||
ServerMetricCatalogEntry: {
|
||||
metricName?: string;
|
||||
metricType?: string;
|
||||
statistics?: string[];
|
||||
tagKeys?: string[];
|
||||
};
|
||||
SensitiveKeysConfig: {
|
||||
keys?: string[];
|
||||
};
|
||||
@@ -6246,6 +6373,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
query: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["QueryBody"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ServerMetricQueryResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listRoles: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -7068,6 +7219,7 @@ export interface operations {
|
||||
agentId?: string;
|
||||
processorType?: string;
|
||||
application?: string;
|
||||
attr?: string[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
sortField?: string;
|
||||
@@ -7822,6 +7974,52 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
instances: {
|
||||
parameters: {
|
||||
query?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ServerInstanceInfo"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
catalog: {
|
||||
parameters: {
|
||||
query?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ServerMetricCatalogEntry"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getStats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -44,6 +44,7 @@ import { EnvironmentSwitcherModal } from './EnvironmentSwitcherModal';
|
||||
import { envColorVar } from './env-colors';
|
||||
import { useScope } from '../hooks/useScope';
|
||||
import { formatDuration } from '../utils/format-utils';
|
||||
import { parseFacetQuery, formatAttrParam } from '../utils/attribute-filter';
|
||||
import {
|
||||
buildAppTreeNodes,
|
||||
buildAdminTreeNodes,
|
||||
@@ -111,7 +112,11 @@ function buildSearchData(
|
||||
id: `attr-key-${key}`,
|
||||
category: 'attribute',
|
||||
title: key,
|
||||
meta: 'attribute key',
|
||||
meta: 'attribute key — filter list',
|
||||
// Path carries the facet in query-string form; handlePaletteSelect routes
|
||||
// attribute results to the current scope, so the leading segment below is
|
||||
// only used as a fallback when no scope is active.
|
||||
path: `/exchanges?attr=${encodeURIComponent(key)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -690,7 +695,19 @@ function LayoutContent() {
|
||||
}
|
||||
}
|
||||
|
||||
return [...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
|
||||
const facet = parseFacetQuery(debouncedQuery ?? '');
|
||||
const facetItems: SearchResult[] =
|
||||
facet
|
||||
? [{
|
||||
id: `facet-${formatAttrParam(facet)}`,
|
||||
category: 'attribute' as const,
|
||||
title: `Filter: ${facet.key} = "${facet.value}"${facet.value?.includes('*') ? ' (wildcard)' : ''}`,
|
||||
meta: 'apply attribute filter',
|
||||
path: `/exchanges?attr=${encodeURIComponent(formatAttrParam(facet))}`,
|
||||
}]
|
||||
: [];
|
||||
|
||||
return [...facetItems, ...catalogRef.current, ...exchangeItems, ...attributeItems, ...alertingSearchData];
|
||||
}, [isAdminPage, catalogRef.current, exchangeResults, debouncedQuery, alertingSearchData]);
|
||||
|
||||
const searchData = isAdminPage ? adminSearchData : operationalSearchData;
|
||||
@@ -744,6 +761,32 @@ function LayoutContent() {
|
||||
setPaletteOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.category === 'attribute') {
|
||||
// Three sources feed 'attribute' results:
|
||||
// - buildSearchData → id `attr-key-<key>` (key-only)
|
||||
// - operationalSearchData per-exchange → id `<execId>-attr-<key>`, title `key = "value"`
|
||||
// - synthetic facet (Task 9) → id `facet-<serialized>` where <serialized> is already
|
||||
// the URL `attr=` form (`key` or `key:value`)
|
||||
let attrParam: string | null = null;
|
||||
if (typeof result.id === 'string' && result.id.startsWith('attr-key-')) {
|
||||
attrParam = result.id.substring('attr-key-'.length);
|
||||
} else if (typeof result.id === 'string' && result.id.startsWith('facet-')) {
|
||||
attrParam = result.id.substring('facet-'.length);
|
||||
} else if (typeof result.title === 'string') {
|
||||
const m = /^([a-zA-Z0-9._-]+)\s*=\s*"([^"]*)"/.exec(result.title);
|
||||
if (m) attrParam = `${m[1]}:${m[2]}`;
|
||||
}
|
||||
if (attrParam) {
|
||||
const base = ['/exchanges'];
|
||||
if (scope.appId) base.push(scope.appId);
|
||||
if (scope.routeId) base.push(scope.routeId);
|
||||
navigate(`${base.join('/')}?attr=${encodeURIComponent(attrParam)}`);
|
||||
}
|
||||
setPaletteOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.path) {
|
||||
if (ADMIN_CATEGORIES.has(result.category)) {
|
||||
const itemId = result.id.split(':').slice(1).join(':');
|
||||
@@ -752,7 +795,7 @@ function LayoutContent() {
|
||||
});
|
||||
} else {
|
||||
const state: Record<string, unknown> = { sidebarReveal: result.path };
|
||||
if (result.category === 'exchange' || result.category === 'attribute') {
|
||||
if (result.category === 'exchange') {
|
||||
const parts = result.path.split('/').filter(Boolean);
|
||||
if (parts.length === 4 && parts[0] === 'exchanges') {
|
||||
state.selectedExchange = {
|
||||
@@ -766,7 +809,7 @@ function LayoutContent() {
|
||||
}
|
||||
}
|
||||
setPaletteOpen(false);
|
||||
}, [navigate, setPaletteOpen]);
|
||||
}, [navigate, setPaletteOpen, scope.appId, scope.routeId]);
|
||||
|
||||
const handlePaletteSubmit = useCallback((query: string) => {
|
||||
if (isAdminPage) {
|
||||
@@ -780,12 +823,18 @@ function LayoutContent() {
|
||||
} else {
|
||||
navigate('/admin/rbac');
|
||||
}
|
||||
} else {
|
||||
const baseParts = ['/exchanges'];
|
||||
if (scope.appId) baseParts.push(scope.appId);
|
||||
if (scope.routeId) baseParts.push(scope.routeId);
|
||||
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const facet = parseFacetQuery(query);
|
||||
const baseParts = ['/exchanges'];
|
||||
if (scope.appId) baseParts.push(scope.appId);
|
||||
if (scope.routeId) baseParts.push(scope.routeId);
|
||||
if (facet) {
|
||||
navigate(`${baseParts.join('/')}?attr=${encodeURIComponent(formatAttrParam(facet))}`);
|
||||
return;
|
||||
}
|
||||
navigate(`${baseParts.join('/')}?text=${encodeURIComponent(query)}`);
|
||||
}, [isAdminPage, adminSearchData, handlePaletteSelect, navigate, scope.appId, scope.routeId]);
|
||||
|
||||
const handleSidebarNavigate = useCallback((path: string) => {
|
||||
|
||||
@@ -139,3 +139,23 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attrChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.attrChip code {
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { useEnvironmentStore } from '../../api/environment-store'
|
||||
import type { ExecutionSummary } from '../../api/types'
|
||||
import { attributeBadgeColor } from '../../utils/attribute-color'
|
||||
import { parseAttrParam, formatAttrParam } from '../../utils/attribute-filter';
|
||||
import type { AttributeFilter } from '../../utils/attribute-filter';
|
||||
import { formatDuration, statusLabel } from '../../utils/format-utils'
|
||||
import styles from './Dashboard.module.css'
|
||||
import tableStyles from '../../styles/table-section.module.css'
|
||||
@@ -84,7 +86,7 @@ function buildColumns(hasAttributes: boolean): Column<Row>[] {
|
||||
<div className={styles.attrCell}>
|
||||
{shown.map(([k, v]) => (
|
||||
<span key={k} title={k}>
|
||||
<Badge label={String(v)} color={attributeBadgeColor(String(v))} />
|
||||
<Badge label={String(v)} color={attributeBadgeColor(k)} />
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && <span className={styles.attrOverflow}>+{overflow}</span>}
|
||||
@@ -147,6 +149,12 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const textFilter = searchParams.get('text') || undefined
|
||||
const attributeFilters = useMemo<AttributeFilter[]>(
|
||||
() => searchParams.getAll('attr')
|
||||
.map(parseAttrParam)
|
||||
.filter((f): f is AttributeFilter => f != null),
|
||||
[searchParams],
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(activeExchangeId)
|
||||
const [sortField, setSortField] = useState<string>('startTime')
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
@@ -180,12 +188,13 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
environment: selectedEnv,
|
||||
status: statusParam,
|
||||
text: textFilter,
|
||||
attributeFilters: attributeFilters.length > 0 ? attributeFilters : undefined,
|
||||
sortField,
|
||||
sortDir,
|
||||
offset: 0,
|
||||
limit: textFilter ? 200 : 50,
|
||||
limit: textFilter || attributeFilters.length > 0 ? 200 : 50,
|
||||
},
|
||||
!textFilter,
|
||||
!textFilter && attributeFilters.length === 0,
|
||||
)
|
||||
|
||||
// ─── Rows ────────────────────────────────────────────────────────────────
|
||||
@@ -221,17 +230,46 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
<div className={`${tableStyles.tableSection} ${styles.tableWrap}`}>
|
||||
<div className={tableStyles.tableHeader}>
|
||||
<span className={tableStyles.tableTitle}>
|
||||
{textFilter ? (
|
||||
{textFilter || attributeFilters.length > 0 ? (
|
||||
<>
|
||||
<Search size={14} style={{ marginRight: 4, verticalAlign: -2 }} />
|
||||
Search: “{textFilter}”
|
||||
<button
|
||||
className={styles.clearSearch}
|
||||
onClick={() => setSearchParams({})}
|
||||
title="Clear search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{textFilter && (
|
||||
<>
|
||||
Search: “{textFilter}”
|
||||
<button
|
||||
className={styles.clearSearch}
|
||||
onClick={() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('text');
|
||||
setSearchParams(next);
|
||||
}}
|
||||
title="Clear text search"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{attributeFilters.map((f, i) => (
|
||||
<span key={`${f.key}:${f.value ?? ''}:${i}`} className={styles.attrChip}>
|
||||
{f.value === undefined
|
||||
? <>has <code>{f.key}</code></>
|
||||
: <><code>{f.key}</code> = <code>{f.value}</code></>}
|
||||
<button
|
||||
className={styles.clearSearch}
|
||||
onClick={() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
const remaining = next.getAll('attr')
|
||||
.filter(a => a !== formatAttrParam(f));
|
||||
next.delete('attr');
|
||||
remaining.forEach(a => next.append('attr', a));
|
||||
setSearchParams(next);
|
||||
}}
|
||||
title="Remove filter"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : 'Recent Exchanges'}
|
||||
</span>
|
||||
@@ -239,7 +277,7 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo
|
||||
<span className={tableStyles.tableMeta}>
|
||||
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
||||
</span>
|
||||
{!textFilter && <Badge label="AUTO" color="success" />}
|
||||
{!textFilter && attributeFilters.length === 0 && <Badge label="AUTO" color="success" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
69
ui/src/utils/attribute-filter.test.ts
Normal file
69
ui/src/utils/attribute-filter.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAttrParam, formatAttrParam, parseFacetQuery } from './attribute-filter';
|
||||
|
||||
describe('parseAttrParam', () => {
|
||||
it('returns key-only for input without colon', () => {
|
||||
expect(parseAttrParam('order')).toEqual({ key: 'order' });
|
||||
});
|
||||
|
||||
it('splits on first colon, trims key, preserves value as-is', () => {
|
||||
expect(parseAttrParam('order:47')).toEqual({ key: 'order', value: '47' });
|
||||
});
|
||||
|
||||
it('treats a value containing colons as a single value', () => {
|
||||
expect(parseAttrParam('trace-id:abc:123')).toEqual({ key: 'trace-id', value: 'abc:123' });
|
||||
});
|
||||
|
||||
it('returns null for blank input', () => {
|
||||
expect(parseAttrParam('')).toBeNull();
|
||||
expect(parseAttrParam(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for missing key', () => {
|
||||
expect(parseAttrParam(':x')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the key contains invalid characters', () => {
|
||||
expect(parseAttrParam('bad key:1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAttrParam', () => {
|
||||
it('returns bare key for key-only filter', () => {
|
||||
expect(formatAttrParam({ key: 'order' })).toBe('order');
|
||||
});
|
||||
|
||||
it('joins with colon when value is present', () => {
|
||||
expect(formatAttrParam({ key: 'order', value: '47' })).toBe('order:47');
|
||||
});
|
||||
|
||||
it('joins with colon when value is empty string', () => {
|
||||
expect(formatAttrParam({ key: 'order', value: '' })).toBe('order:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFacetQuery', () => {
|
||||
it('matches `key: value`', () => {
|
||||
expect(parseFacetQuery('order: 47')).toEqual({ key: 'order', value: '47' });
|
||||
});
|
||||
|
||||
it('matches `key:value` without spaces', () => {
|
||||
expect(parseFacetQuery('order:47')).toEqual({ key: 'order', value: '47' });
|
||||
});
|
||||
|
||||
it('matches wildcard values', () => {
|
||||
expect(parseFacetQuery('order: 4*')).toEqual({ key: 'order', value: '4*' });
|
||||
});
|
||||
|
||||
it('returns null when the key contains invalid characters', () => {
|
||||
expect(parseFacetQuery('bad key: 1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without a colon', () => {
|
||||
expect(parseFacetQuery('order')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with an empty value side', () => {
|
||||
expect(parseFacetQuery('order: ')).toBeNull();
|
||||
});
|
||||
});
|
||||
37
ui/src/utils/attribute-filter.ts
Normal file
37
ui/src/utils/attribute-filter.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface AttributeFilter {
|
||||
key: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const KEY_REGEX = /^[a-zA-Z0-9._-]+$/;
|
||||
|
||||
/** Parses a single `?attr=` URL value. Returns null for invalid / blank input. */
|
||||
export function parseAttrParam(raw: string): AttributeFilter | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
const colon = trimmed.indexOf(':');
|
||||
if (colon < 0) {
|
||||
return KEY_REGEX.test(trimmed) ? { key: trimmed } : null;
|
||||
}
|
||||
|
||||
const key = trimmed.substring(0, colon).trim();
|
||||
const value = raw.substring(raw.indexOf(':') + 1);
|
||||
if (!KEY_REGEX.test(key)) return null;
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
/** Serialises an AttributeFilter back to a URL `?attr=` value. */
|
||||
export function formatAttrParam(f: AttributeFilter): string {
|
||||
return f.value === undefined ? f.key : `${f.key}:${f.value}`;
|
||||
}
|
||||
|
||||
const FACET_REGEX = /^\s*([a-zA-Z0-9._-]+)\s*:\s*(\S(?:.*\S)?)\s*$/;
|
||||
|
||||
/** Parses a cmd-k query like `order: 47` into a facet descriptor. */
|
||||
export function parseFacetQuery(query: string): AttributeFilter | null {
|
||||
const m = FACET_REGEX.exec(query);
|
||||
if (!m) return null;
|
||||
return { key: m[1], value: m[2] };
|
||||
}
|
||||
Reference in New Issue
Block a user