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

This commit is contained in:
2026-04-24 13:49:51 +02:00
16 changed files with 829 additions and 33 deletions

File diff suppressed because one or more lines are too long

200
ui/src/api/schema.d.ts vendored
View File

@@ -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;

View File

@@ -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) => {

View File

@@ -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);
}

View File

@@ -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: &ldquo;{textFilter}&rdquo;
<button
className={styles.clearSearch}
onClick={() => setSearchParams({})}
title="Clear search"
>
<X size={12} />
</button>
{textFilter && (
<>
Search: &ldquo;{textFilter}&rdquo;
<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>

View 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();
});
});

View 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] };
}