fix: resolve UI glitches and improve consistency
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m22s
CI / docker (push) Successful in 1m36s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

- Sidebar: make +App button more subtle (lower opacity, brightens on hover)
- Sidebar: add filter chips to hide empty routes and offline/stale apps
- Sidebar: hide filter chips and +App button when sidebar is collapsed
- Exchange table: reorder columns to Status, Attributes, App, Route, Started, Duration; remove ExchangeId and Agent columns
- Exchange detail log tab: query by exchangeId only (no applicationId required), filter by processorId when processor selected
- KPI tooltips: styled tooltips with current/previous values, time period labels, percentage change, themed with DS variables
- KPI tooltips: fix overflow by left-aligning first two and right-aligning last two
- Exchange detail: show full datetime (YYYY-MM-DD HH:mm:ss.SSS) for start/end times
- Status labels: unify to title-case (Completed, Failed, Running) across all views
- Status filter buttons: match title-case labels (Completed, Warning, Failed, Running)
- Create app: show full external URL using routingDomain from env config or window.location.origin fallback
- Create app: add Runtime Type selector and Custom Arguments to Resources tab
- Create app: add Sensitive Keys tab with agent defaults, global keys, and app-specific keys (matching admin page design)
- Create app: add placeholder text to all Input fields for consistency
- Update design-system to 0.1.52 (sidebar collapse toggle fix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-15 19:41:36 +02:00
parent 091dfb34d0
commit 457650012b
12 changed files with 493 additions and 102 deletions

View File

@@ -1,7 +1,7 @@
import { Badge } from '@cameleer/design-system';
import type { ProcessorNode, ExecutionDetail } from '../types';
import { attributeBadgeColor } from '../../../utils/attribute-color';
import { formatDurationShort } from '../../../utils/format-utils';
import { formatDurationShort, statusLabel } from '../../../utils/format-utils';
import styles from '../ExecutionDiagram.module.css';
interface InfoTabProps {
@@ -13,11 +13,14 @@ function formatTime(iso: string | undefined): string {
if (!iso) return '-';
try {
const d = new Date(iso);
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
const s = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return `${h}:${m}:${s}.${ms}`;
return `${y}-${mo}-${day} ${h}:${m}:${s}.${ms}`;
} catch {
return iso;
}
@@ -66,7 +69,7 @@ export function InfoTab({ processor, executionDetail }: InfoTabProps) {
<div>
<div className={styles.fieldLabel}>Status</div>
<span className={`${styles.statusBadge} ${statusClass(processor.status)}`}>
{processor.status}
{statusLabel(processor.status)}
</span>
</div>
@@ -96,7 +99,7 @@ export function InfoTab({ processor, executionDetail }: InfoTabProps) {
<div>
<div className={styles.fieldLabel}>Status</div>
<span className={`${styles.statusBadge} ${statusClass(executionDetail.status)}`}>
{executionDetail.status}
{statusLabel(executionDetail.status)}
</span>
</div>

View File

@@ -2,7 +2,8 @@ import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { Input, Button, LogViewer } from '@cameleer/design-system';
import type { LogEntry } from '@cameleer/design-system';
import { useApplicationLogs } from '../../../api/queries/logs';
import { useLogs } from '../../../api/queries/logs';
import type { LogEntryResponse } from '../../../api/queries/logs';
import { mapLogLevel } from '../../../utils/agent-utils';
import logStyles from './LogTab.module.css';
import diagramStyles from '../ExecutionDiagram.module.css';
@@ -13,26 +14,33 @@ interface LogTabProps {
processorId: string | null;
}
function matchesProcessor(e: LogEntryResponse, pid: string): boolean {
if (e.message?.includes(pid)) return true;
if (e.loggerName?.includes(pid)) return true;
if (e.mdc) {
for (const v of Object.values(e.mdc)) {
if (v === pid) return true;
}
}
return false;
}
export function LogTab({ applicationId, exchangeId, processorId }: LogTabProps) {
const [filter, setFilter] = useState('');
const navigate = useNavigate();
const { data: logs, isLoading } = useApplicationLogs(
applicationId,
undefined,
const { data: logPage, isLoading } = useLogs(
{ exchangeId, limit: 500 },
{ enabled: !!exchangeId },
);
const entries = useMemo<LogEntry[]>(() => {
if (!logs) return [];
let items = [...logs];
if (!logPage?.data) return [];
let items = [...logPage.data];
// If a processor is selected, filter logs by logger name containing the processor ID
// If a processor is selected, filter logs to that processor
if (processorId) {
items = items.filter((e) =>
e.message?.includes(processorId) ||
e.loggerName?.includes(processorId)
);
items = items.filter((e) => matchesProcessor(e, processorId));
}
// Text filter
@@ -50,7 +58,7 @@ export function LogTab({ applicationId, exchangeId, processorId }: LogTabProps)
level: mapLogLevel(e.level),
message: e.message ?? '',
}));
}, [logs, processorId, filter]);
}, [logPage, processorId, filter]);
if (isLoading) {
return <div className={diagramStyles.emptyState}>Loading logs...</div>;

View File

@@ -62,15 +62,57 @@
font-size: 12px;
font-weight: 500;
color: var(--sidebar-muted);
opacity: 0.45;
cursor: pointer;
transition: color 0.12s, background 0.12s;
transition: color 0.12s, background 0.12s, opacity 0.12s;
}
.addAppBtn:hover {
opacity: 1;
color: var(--amber);
background: rgba(255, 255, 255, 0.06);
}
.sidebarFilters {
display: flex;
gap: 6px;
padding: 4px 12px 6px;
}
.filterChip {
display: flex;
align-items: center;
gap: 4px;
background: none;
border: 1px solid transparent;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
color: var(--sidebar-muted);
cursor: pointer;
white-space: nowrap;
transition: color 0.12s, border-color 0.12s, background 0.12s;
opacity: 0.6;
}
.filterChip:hover {
opacity: 1;
color: var(--sidebar-text);
border-color: rgba(255, 255, 255, 0.12);
}
.filterChipActive {
opacity: 1;
color: var(--amber);
border-color: var(--amber);
background: rgba(var(--amber-rgb, 245, 158, 11), 0.08);
}
.filterChipIcon {
display: flex;
align-items: center;
}
.mainContent {
flex: 1;
display: flex;

View File

@@ -21,7 +21,7 @@ import {
} from '@cameleer/design-system';
import type { SearchResult, SidebarTreeNode, DropdownItem, ButtonGroupItem, ExchangeStatus } from '@cameleer/design-system';
import sidebarLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus } from 'lucide-react';
import { Box, Settings, FileText, ChevronRight, Square, Pause, Star, X, User, Plus, EyeOff } from 'lucide-react';
import { AboutMeDialog } from './AboutMeDialog';
import css from './LayoutShell.module.css';
import { useQueryClient } from '@tanstack/react-query';
@@ -270,9 +270,9 @@ function StarredList({ items, onNavigate, onRemove }: { items: StarredItem[]; on
/* ------------------------------------------------------------------ */
const STATUS_ITEMS: ButtonGroupItem[] = [
{ value: 'completed', label: 'OK', color: 'var(--success)' },
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
{ value: 'failed', label: 'Error', color: 'var(--error)' },
{ value: 'completed', label: 'Completed', color: 'var(--success)' },
{ value: 'warning', label: 'Warning', color: 'var(--warning)' },
{ value: 'failed', label: 'Failed', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]
@@ -345,6 +345,15 @@ function LayoutContent() {
// --- Sidebar filter -----------------------------------------------
const [filterQuery, setFilterQuery] = useState('');
const [hideEmptyRoutes, setHideEmptyRoutes] = useState(() => readCollapsed('sidebar:hideEmptyRoutes', false));
const [hideOfflineApps, setHideOfflineApps] = useState(() => readCollapsed('sidebar:hideOfflineApps', false));
const toggleHideEmptyRoutes = useCallback(() => {
setHideEmptyRoutes((prev) => { writeCollapsed('sidebar:hideEmptyRoutes', !prev); return !prev; });
}, []);
const toggleHideOfflineApps = useCallback(() => {
setHideOfflineApps((prev) => { writeCollapsed('sidebar:hideOfflineApps', !prev); return !prev; });
}, []);
const setSelectedEnv = useCallback((env: string | undefined) => {
setSelectedEnvRaw(env);
@@ -430,10 +439,27 @@ function LayoutContent() {
}));
}, [catalog]);
// --- Apply sidebar filters -----------------------------------------
const filteredSidebarApps: SidebarApp[] = useMemo(() => {
let apps = sidebarApps;
if (hideOfflineApps) {
apps = apps.filter((a) => a.health !== 'dead' && a.health !== 'stale');
}
if (hideEmptyRoutes) {
apps = apps
.map((a) => ({
...a,
routes: a.routes.filter((r) => r.exchangeCount > 0),
}))
.filter((a) => a.exchangeCount > 0 || a.routes.length > 0);
}
return apps;
}, [sidebarApps, hideOfflineApps, hideEmptyRoutes]);
// --- Tree nodes ---------------------------------------------------
const appTreeNodes: SidebarTreeNode[] = useMemo(
() => buildAppTreeNodes(sidebarApps, makeStatusDot, makeChevron, makeStopIcon, makePauseIcon),
[sidebarApps],
() => buildAppTreeNodes(filteredSidebarApps, makeStatusDot, makeChevron, makeStopIcon, makePauseIcon),
[filteredSidebarApps],
);
const adminTreeNodes: SidebarTreeNode[] = useMemo(
@@ -692,9 +718,29 @@ function LayoutContent() {
version={__APP_VERSION__}
/>
{/* Sidebar filters */}
{!sidebarCollapsed && <div className={css.sidebarFilters}>
<button
className={`${css.filterChip} ${hideEmptyRoutes ? css.filterChipActive : ''}`}
onClick={toggleHideEmptyRoutes}
title="Hide routes with 0 executions"
>
<span className={css.filterChipIcon}><EyeOff size={10} /></span>
Empty routes
</button>
<button
className={`${css.filterChip} ${hideOfflineApps ? css.filterChipActive : ''}`}
onClick={toggleHideOfflineApps}
title="Hide stale and disconnected apps"
>
<span className={css.filterChipIcon}><EyeOff size={10} /></span>
Offline apps
</button>
</div>}
{/* Applications section */}
<div className={css.appSectionWrap}>
{canControl && (
{canControl && !sidebarCollapsed && (
<button
className={css.addAppBtn}
onClick={(e) => { e.stopPropagation(); navigate('/apps/new'); }}

View File

@@ -42,3 +42,111 @@
.flat {
color: var(--text-muted);
}
/* ── Tooltip ─────────────────────────────────────── */
/* Override DS tooltip bubble: use themed surface instead of inverted style */
.tooltipWrap [role="tooltip"] {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
white-space: normal;
padding: 10px 12px;
border-radius: var(--radius-md);
left: 0;
transform: none;
}
/* Right-align tooltip for items near the edge to prevent overflow */
.tooltipEnd [role="tooltip"] {
left: auto;
right: 0;
transform: none;
}
.tooltipBody {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 180px;
}
.tooltipTitle {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
padding-bottom: 6px;
border-bottom: 1px solid var(--border-subtle);
}
.tooltipRows {
display: flex;
flex-direction: column;
gap: 6px;
}
.tooltipRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.tooltipPeriod {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.tooltipDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--amber);
flex-shrink: 0;
}
.tooltipDotPrev {
background: var(--text-muted);
opacity: 0.4;
}
.tooltipPeriodLabel {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-secondary);
flex-shrink: 0;
}
.tooltipTime {
font-family: var(--font-mono);
font-size: 0.625rem;
color: var(--text-muted);
white-space: nowrap;
}
.tooltipValue {
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-primary);
flex-shrink: 0;
}
.tooltipValuePrev {
font-weight: 500;
color: var(--text-muted);
}
.tooltipChange {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
padding-top: 6px;
border-top: 1px solid var(--border-subtle);
text-align: right;
}

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { useGlobalFilters } from '@cameleer/design-system';
import { useGlobalFilters, Tooltip } from '@cameleer/design-system';
import { useExecutionStats } from '../api/queries/executions';
import type { Scope } from '../hooks/useScope';
import { formatPercent } from '../utils/format-utils';
@@ -37,14 +37,99 @@ function trendArrow(t: Trend): string {
}
}
function changePercent(current: number, previous: number): string | null {
if (previous === 0 && current === 0) return null;
if (previous === 0) return '+\u221e%';
const pct = ((current - previous) / previous) * 100;
const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(1)}%`;
}
/* ── Time period labels ───────────────────────────── */
function shortTime(d: Date): string {
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${h}:${m}`;
}
function shortDate(d: Date): string {
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return `${months[d.getMonth()]} ${d.getDate()}`;
}
function formatRange(start: Date, end: Date): string {
const sameDay = start.toDateString() === end.toDateString();
if (sameDay) return `${shortDate(start)} ${shortTime(start)}\u2013${shortTime(end)}`;
return `${shortDate(start)} ${shortTime(start)} \u2013 ${shortDate(end)} ${shortTime(end)}`;
}
function computePreviousPeriod(start: Date, end: Date): { label: string; prevLabel: string } {
const durationMs = end.getTime() - start.getTime();
const prevStart = new Date(start.getTime() - durationMs);
const prevEnd = new Date(start.getTime());
return {
label: formatRange(start, end),
prevLabel: formatRange(prevStart, prevEnd),
};
}
/* ── Metric model ─────────────────────────────────── */
interface Metric {
label: string;
fullLabel: string;
value: string;
prevValue: string;
change: string | null;
trend: Trend;
/** Whether "up" is bad (e.g. error rate, latency) */
upIsBad?: boolean;
}
/* ── Tooltip ──────────────────────────────────────── */
function MetricTooltip({ m, currentLabel, prevLabel }: { m: Metric; currentLabel: string; prevLabel: string }) {
const trendClass = m.trend === 'flat'
? styles.flat
: (m.trend === 'up') === m.upIsBad
? styles.bad
: styles.good;
return (
<div className={styles.tooltipBody}>
<div className={styles.tooltipTitle}>{m.fullLabel}</div>
<div className={styles.tooltipRows}>
<div className={styles.tooltipRow}>
<div className={styles.tooltipPeriod}>
<span className={styles.tooltipDot} />
<span className={styles.tooltipPeriodLabel}>Now</span>
<span className={styles.tooltipTime}>{currentLabel}</span>
</div>
<span className={styles.tooltipValue}>{m.value}</span>
</div>
<div className={styles.tooltipRow}>
<div className={styles.tooltipPeriod}>
<span className={`${styles.tooltipDot} ${styles.tooltipDotPrev}`} />
<span className={styles.tooltipPeriodLabel}>Prev</span>
<span className={styles.tooltipTime}>{prevLabel}</span>
</div>
<span className={`${styles.tooltipValue} ${styles.tooltipValuePrev}`}>{m.prevValue}</span>
</div>
</div>
{m.change && (
<div className={`${styles.tooltipChange} ${trendClass}`}>
{trendArrow(m.trend)} {m.change}
</div>
)}
</div>
);
}
/* ── Main component ───────────────────────────────── */
export function TabKpis({ scope }: TabKpisProps) {
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
@@ -55,6 +140,11 @@ export function TabKpis({ scope }: TabKpisProps) {
scope.routeId, scope.appId,
);
const { label: currentLabel, prevLabel } = useMemo(
() => computePreviousPeriod(timeRange.start, timeRange.end),
[timeRange.start, timeRange.end],
);
const metrics: Metric[] = useMemo(() => {
if (!stats) return [];
@@ -70,10 +160,10 @@ export function TabKpis({ scope }: TabKpisProps) {
const prevP99 = stats.prevP99LatencyMs ?? 0;
return [
{ label: 'Total', value: formatNum(total), trend: trend(total, prevTotal) },
{ label: 'Err%', value: formatPercent(errorRate), trend: trend(errorRate, prevErrorRate), upIsBad: true },
{ label: 'Avg', value: formatMs(avgMs), trend: trend(avgMs, prevAvgMs), upIsBad: true },
{ label: 'P99', value: formatMs(p99), trend: trend(p99, prevP99), upIsBad: true },
{ label: 'Total', fullLabel: 'Total Exchanges', value: formatNum(total), prevValue: formatNum(prevTotal), change: changePercent(total, prevTotal), trend: trend(total, prevTotal) },
{ label: 'Err%', fullLabel: 'Error Rate', value: formatPercent(errorRate), prevValue: formatPercent(prevErrorRate), change: changePercent(errorRate, prevErrorRate), trend: trend(errorRate, prevErrorRate), upIsBad: true },
{ label: 'Avg', fullLabel: 'Avg Latency', value: formatMs(avgMs), prevValue: formatMs(prevAvgMs), change: changePercent(avgMs, prevAvgMs), trend: trend(avgMs, prevAvgMs), upIsBad: true },
{ label: 'P99', fullLabel: 'P99 Latency', value: formatMs(p99), prevValue: formatMs(prevP99), change: changePercent(p99, prevP99), trend: trend(p99, prevP99), upIsBad: true },
];
}, [stats]);
@@ -81,19 +171,27 @@ export function TabKpis({ scope }: TabKpisProps) {
return (
<div className={styles.kpis}>
{metrics.map((m) => {
{metrics.map((m, i) => {
const arrowClass = m.trend === 'flat'
? styles.flat
: (m.trend === 'up') === m.upIsBad
? styles.bad
: styles.good;
const isNearEnd = i >= metrics.length - 2;
return (
<div key={m.label} className={styles.metric}>
<span className={styles.label}>{m.label}</span>
<span className={styles.value}>{m.value}</span>
<span className={`${styles.arrow} ${arrowClass}`}>{trendArrow(m.trend)}</span>
</div>
<Tooltip
key={m.label}
content={<MetricTooltip m={m} currentLabel={currentLabel} prevLabel={prevLabel} />}
position="bottom"
className={`${styles.tooltipWrap} ${isNearEnd ? styles.tooltipEnd : ''}`}
>
<div className={styles.metric}>
<span className={styles.label}>{m.label}</span>
<span className={styles.value}>{m.value}</span>
<span className={`${styles.arrow} ${arrowClass}`}>{trendArrow(m.trend)}</span>
</div>
</Tooltip>
);
})}
</div>