fix: resolve UI glitches and improve consistency
- 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:
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@cameleer/design-system": "^0.1.50",
|
||||
"@cameleer/design-system": "^0.1.52",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
@@ -281,9 +281,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cameleer/design-system": {
|
||||
"version": "0.1.51",
|
||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.51/design-system-0.1.51.tgz",
|
||||
"integrity": "sha512-ppZSiR6ZzzrUbtHTtnwpU4Zr2LPbcbJfAn0Ayh/OzDf9k6kFjn5myJWFlg+VJAZkFQoJA5y76GcKBdJ8nty4Tw==",
|
||||
"version": "0.1.52",
|
||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.1.52/design-system-0.1.52.tgz",
|
||||
"integrity": "sha512-yhxZodFoqGbucNdjRtnlmTt+SI3csv0+nOf8nvD6hmsOjj0WhaqMjdj+hqPpc6EZu3UVEWjfeX+9d/1B7cyy0A==",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"postinstall": "node -e \"const fs=require('fs');fs.mkdirSync('public',{recursive:true});fs.copyFileSync('node_modules/@cameleer/design-system/assets/cameleer-logo.svg','public/favicon.svg')\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@cameleer/design-system": "^0.1.50",
|
||||
"@cameleer/design-system": "^0.1.52",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'); }}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
Select,
|
||||
StatusDot,
|
||||
Tabs,
|
||||
Tag,
|
||||
Toggle,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { Shield, Info } from 'lucide-react';
|
||||
import { EnvEditor } from '../../components/EnvEditor';
|
||||
import { useEnvironmentStore } from '../../api/environment-store';
|
||||
import { useEnvironments } from '../../api/queries/admin/environments';
|
||||
@@ -37,6 +39,7 @@ import type { Environment } from '../../api/queries/admin/environments';
|
||||
import { useApplicationConfig, useUpdateApplicationConfig, useProcessorRouteMapping } from '../../api/queries/commands';
|
||||
import type { ApplicationConfig, TapDefinition } from '../../api/queries/commands';
|
||||
import { useCatalog } from '../../api/queries/catalog';
|
||||
import { useSensitiveKeys } from '../../api/queries/admin/sensitive-keys';
|
||||
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
|
||||
import { DeploymentProgress } from '../../components/DeploymentProgress';
|
||||
import { StartupLogPanel } from '../../components/StartupLogPanel';
|
||||
@@ -46,6 +49,7 @@ import { PageLoader } from '../../components/PageLoader';
|
||||
import styles from './AppsTab.module.css';
|
||||
import sectionStyles from '../../styles/section-card.module.css';
|
||||
import tableStyles from '../../styles/table-section.module.css';
|
||||
import skStyles from '../Admin/SensitiveKeysPage.module.css';
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||
@@ -166,6 +170,8 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
const createApp = useCreateApp();
|
||||
const uploadJar = useUploadJar();
|
||||
const createDeployment = useCreateDeployment();
|
||||
const { data: globalKeysConfig } = useSensitiveKeys();
|
||||
const globalKeys = globalKeysConfig?.keys ?? [];
|
||||
const updateAgentConfig = useUpdateApplicationConfig();
|
||||
const updateContainerConfig = useUpdateContainerConfig();
|
||||
|
||||
@@ -215,7 +221,11 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
const [extraNetworks, setExtraNetworks] = useState<string[]>(Array.isArray(defaults.extraNetworks) ? defaults.extraNetworks as string[] : []);
|
||||
const [newNetwork, setNewNetwork] = useState('');
|
||||
|
||||
const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables'>('monitoring');
|
||||
// Sensitive keys
|
||||
const [sensitiveKeys, setSensitiveKeys] = useState<string[]>([]);
|
||||
const [newSensitiveKey, setNewSensitiveKey] = useState('');
|
||||
|
||||
const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables' | 'sensitive-keys'>('monitoring');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [step, setStep] = useState('');
|
||||
|
||||
@@ -292,6 +302,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
taps: [],
|
||||
tapVersion: 0,
|
||||
routeRecording: {},
|
||||
sensitiveKeys: sensitiveKeys.length > 0 ? sensitiveKeys : undefined,
|
||||
},
|
||||
environment: selectedEnv,
|
||||
});
|
||||
@@ -337,7 +348,16 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} />
|
||||
|
||||
<span className={styles.configLabel}>External URL</span>
|
||||
<MonoText size="sm">/{env?.slug ?? '...'}/{slug || '...'}/</MonoText>
|
||||
<MonoText size="sm">{(() => {
|
||||
const domain = String(defaults.routingDomain ?? '');
|
||||
const envSlug = env?.slug ?? '...';
|
||||
const appSlug = slug || '...';
|
||||
if (defaults.routingMode === 'subdomain' && domain) {
|
||||
return `https://${appSlug}-${envSlug}.${domain}/`;
|
||||
}
|
||||
const base = domain ? `https://${domain}` : window.location.origin;
|
||||
return `${base}/${envSlug}/${appSlug}/`;
|
||||
})()}</MonoText>
|
||||
|
||||
<span className={styles.configLabel}>Environment</span>
|
||||
<Select value={envId} onChange={(e) => setEnvId(e.target.value)} disabled={busy}
|
||||
@@ -370,6 +390,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
{ label: 'Monitoring', value: 'monitoring' },
|
||||
{ label: 'Resources', value: 'resources' },
|
||||
{ label: 'Variables', value: 'variables' },
|
||||
{ label: 'Sensitive Keys', value: 'sensitive-keys' },
|
||||
]}
|
||||
active={configTab}
|
||||
onChange={(v) => setConfigTab(v as typeof configTab)}
|
||||
@@ -396,7 +417,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
|
||||
<span className={styles.configLabel}>Max Payload Size</span>
|
||||
<div className={styles.configInline}>
|
||||
<Input disabled={busy} value={payloadSize} onChange={(e) => setPayloadSize(e.target.value)} className={styles.inputMd} />
|
||||
<Input disabled={busy} value={payloadSize} onChange={(e) => setPayloadSize(e.target.value)} className={styles.inputMd} placeholder="e.g. 4" />
|
||||
<Select disabled={busy} value={payloadUnit} onChange={(e) => setPayloadUnit(e.target.value)} className={styles.inputXl}
|
||||
options={[{ value: 'bytes', label: 'bytes' }, { value: 'KB', label: 'KB' }, { value: 'MB', label: 'MB' }]} />
|
||||
</div>
|
||||
@@ -414,12 +435,12 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
<Toggle checked={metricsEnabled} onChange={() => !busy && setMetricsEnabled(!metricsEnabled)} disabled={busy} />
|
||||
<span className={metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{metricsEnabled ? 'Enabled' : 'Disabled'}</span>
|
||||
<span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span>
|
||||
<Input disabled={busy} value={metricsInterval} onChange={(e) => setMetricsInterval(e.target.value)} className={styles.inputXs} />
|
||||
<Input disabled={busy} value={metricsInterval} onChange={(e) => setMetricsInterval(e.target.value)} className={styles.inputXs} placeholder="60" />
|
||||
<span className={styles.cellMeta}>s</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Sampling Rate</span>
|
||||
<Input disabled={busy} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} className={styles.inputLg} />
|
||||
<Input disabled={busy} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} className={styles.inputLg} placeholder="1.0" />
|
||||
|
||||
<span className={styles.configLabel}>Compress Success</span>
|
||||
<div className={styles.configInline}>
|
||||
@@ -438,6 +459,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
<Toggle checked={routeControlEnabled} onChange={() => !busy && setRouteControlEnabled(!routeControlEnabled)} disabled={busy} />
|
||||
<span className={routeControlEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{routeControlEnabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -446,23 +468,42 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
<div className={sectionStyles.section}>
|
||||
<SectionHeader>Container Resources</SectionHeader>
|
||||
<div className={styles.configGrid}>
|
||||
<span className={styles.configLabel}>Runtime Type</span>
|
||||
<Select disabled={busy} value={runtimeType} onChange={(e) => setRuntimeType(e.target.value)}
|
||||
options={[
|
||||
{ value: 'auto', label: 'Auto (detect from JAR)' },
|
||||
{ value: 'spring-boot', label: 'Spring Boot' },
|
||||
{ value: 'quarkus', label: 'Quarkus' },
|
||||
{ value: 'plain-java', label: 'Plain Java' },
|
||||
{ value: 'native', label: 'Native' },
|
||||
]} />
|
||||
|
||||
<span className={styles.configLabel}>Custom Arguments</span>
|
||||
<div>
|
||||
<Input disabled={busy} value={customArgs} onChange={(e) => setCustomArgs(e.target.value)}
|
||||
placeholder="-Xmx256m -Dfoo=bar" className={styles.inputLg} />
|
||||
<span className={styles.configHint}>
|
||||
{runtimeType === 'native' ? 'Arguments passed to the native binary' : 'Additional JVM arguments appended to the start command'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Memory Limit</span>
|
||||
<div className={styles.configInline}>
|
||||
<Input disabled={busy} value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} className={styles.inputLg} />
|
||||
<Input disabled={busy} value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} className={styles.inputLg} placeholder="e.g. 512" />
|
||||
<span className={styles.cellMeta}>MB</span>
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>Memory Reserve</span>
|
||||
<div>
|
||||
<div className={styles.configInline}>
|
||||
<Input disabled={!isProd || busy} value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="---" className={styles.inputLg} />
|
||||
<Input disabled={!isProd || busy} value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="e.g. 256" className={styles.inputLg} />
|
||||
<span className={styles.cellMeta}>MB</span>
|
||||
</div>
|
||||
{!isProd && <span className={styles.configHint}>Available in production environments only</span>}
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>CPU Request</span>
|
||||
<Input disabled={busy} value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} className={styles.inputLg} />
|
||||
<Input disabled={busy} value={cpuRequest} onChange={(e) => setCpuRequest(e.target.value)} className={styles.inputLg} placeholder="e.g. 500 millicores" />
|
||||
|
||||
<span className={styles.configLabel}>CPU Limit</span>
|
||||
<div className={styles.configInline}>
|
||||
@@ -485,10 +526,10 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
</div>
|
||||
|
||||
<span className={styles.configLabel}>App Port</span>
|
||||
<Input disabled={busy} value={appPort} onChange={(e) => setAppPort(e.target.value)} className={styles.inputLg} />
|
||||
<Input disabled={busy} value={appPort} onChange={(e) => setAppPort(e.target.value)} className={styles.inputLg} placeholder="e.g. 8080" />
|
||||
|
||||
<span className={styles.configLabel}>Replicas</span>
|
||||
<Input disabled={busy} value={replicas} onChange={(e) => setReplicas(e.target.value)} className={styles.inputSm} type="number" />
|
||||
<Input disabled={busy} value={replicas} onChange={(e) => setReplicas(e.target.value)} className={styles.inputSm} type="number" placeholder="1" />
|
||||
|
||||
<span className={styles.configLabel}>Deploy Strategy</span>
|
||||
<Select disabled={busy} value={deployStrategy} onChange={(e) => setDeployStrategy(e.target.value)}
|
||||
@@ -525,6 +566,87 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{configTab === 'sensitive-keys' && (
|
||||
<div className={sectionStyles.section}>
|
||||
<div className={skStyles.sectionTitle}>
|
||||
<Shield size={14} />
|
||||
<span>Agent built-in defaults</span>
|
||||
</div>
|
||||
<div className={skStyles.defaultsList}>
|
||||
{['Authorization', 'Cookie', 'Set-Cookie', 'X-API-Key', 'X-Auth-Token', 'Proxy-Authorization'].map((key) => (
|
||||
<Badge key={key} label={key} variant="outlined" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{globalKeys.length > 0 && (
|
||||
<>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border-subtle)', margin: '10px 0' }} />
|
||||
<div className={skStyles.sectionTitle}>
|
||||
<span>Global keys (enforced)</span>
|
||||
<span className={skStyles.keyCount}>{globalKeys.length}</span>
|
||||
</div>
|
||||
<div className={skStyles.defaultsList}>
|
||||
{globalKeys.map((key) => (
|
||||
<Badge key={key} label={key} color="auto" variant="filled" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border-subtle)', margin: '10px 0' }} />
|
||||
<div className={skStyles.sectionTitle}>
|
||||
<span>Application-specific keys</span>
|
||||
{sensitiveKeys.length > 0 && <span className={skStyles.keyCount}>{sensitiveKeys.length}</span>}
|
||||
</div>
|
||||
|
||||
<div className={skStyles.pillList}>
|
||||
{sensitiveKeys.map((k, i) => (
|
||||
<Tag key={`${k}-${i}`} label={k} onRemove={() => !busy && setSensitiveKeys(sensitiveKeys.filter((_, idx) => idx !== i))} />
|
||||
))}
|
||||
{sensitiveKeys.length === 0 && (
|
||||
<span className={skStyles.emptyState}>No app-specific keys — agents use built-in defaults{globalKeys.length > 0 ? ' and global keys' : ''}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={skStyles.inputRow}>
|
||||
<Input
|
||||
value={newSensitiveKey}
|
||||
onChange={(e) => setNewSensitiveKey(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const v = newSensitiveKey.trim();
|
||||
if (v && !sensitiveKeys.some((k) => k.toLowerCase() === v.toLowerCase())) {
|
||||
setSensitiveKeys([...sensitiveKeys, v]);
|
||||
setNewSensitiveKey('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Add key or glob pattern (e.g. *password*)"
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" disabled={busy || !newSensitiveKey.trim()}
|
||||
onClick={() => {
|
||||
const v = newSensitiveKey.trim();
|
||||
if (v && !sensitiveKeys.some((k) => k.toLowerCase() === v.toLowerCase())) {
|
||||
setSensitiveKeys([...sensitiveKeys, v]);
|
||||
setNewSensitiveKey('');
|
||||
}
|
||||
}}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={skStyles.hint}>
|
||||
<Info size={12} />
|
||||
<span>
|
||||
The final masking configuration is: agent defaults + global keys + app-specific keys.
|
||||
Supports exact header names and glob patterns.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,12 +54,6 @@ function durationClass(ms: number, status: string): string {
|
||||
return styles.durBreach
|
||||
}
|
||||
|
||||
function shortAgentName(name: string): string {
|
||||
const parts = name.split('-')
|
||||
if (parts.length >= 3) return parts.slice(-2).join('-')
|
||||
return name
|
||||
}
|
||||
|
||||
// ─── Table columns ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildColumns(hasAttributes: boolean): Column<Row>[] {
|
||||
@@ -77,22 +71,6 @@ function buildColumns(hasAttributes: boolean): Column<Row>[] {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'routeId',
|
||||
header: 'Route',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.routeName}>{row.routeId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'applicationId',
|
||||
header: 'Application',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.appName}>{row.applicationId ?? ''}</span>
|
||||
),
|
||||
},
|
||||
...(hasAttributes ? [{
|
||||
key: 'attributes' as const,
|
||||
header: 'Attributes',
|
||||
@@ -115,20 +93,19 @@ function buildColumns(hasAttributes: boolean): Column<Row>[] {
|
||||
},
|
||||
}] : []),
|
||||
{
|
||||
key: 'executionId',
|
||||
header: 'Exchange ID',
|
||||
key: 'applicationId',
|
||||
header: 'App',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span
|
||||
title={row.executionId}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(row.executionId);
|
||||
}}
|
||||
>
|
||||
<MonoText size="xs">...{row.executionId.slice(-8)}</MonoText>
|
||||
</span>
|
||||
<span className={styles.appName}>{row.applicationId ?? ''}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'routeId',
|
||||
header: 'Route',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.routeName}>{row.routeId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -149,16 +126,6 @@ function buildColumns(hasAttributes: boolean): Column<Row>[] {
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'instanceId',
|
||||
header: 'Agent',
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.agentBadge}>
|
||||
<span className={styles.agentDot} />
|
||||
<span title={row.instanceId}>{shortAgentName(row.instanceId)}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import type { TapDefinition } from '../../api/queries/commands';
|
||||
import type { ExecutionSummary } from '../../api/types';
|
||||
import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog';
|
||||
import { buildFlowSegments } from '../../utils/diagram-mapping';
|
||||
import { statusLabel } from '../../utils/format-utils';
|
||||
import styles from './RouteDetail.module.css';
|
||||
import tableStyles from '../../styles/table-section.module.css';
|
||||
import rateStyles from '../../styles/rate-colors.module.css';
|
||||
@@ -658,7 +659,7 @@ export default function RouteDetail() {
|
||||
const recentExchangeOptions = useMemo(() =>
|
||||
exchangeRows.slice(0, 20).map(e => ({
|
||||
value: e.executionId,
|
||||
label: `${e.executionId.slice(0, 12)} — ${e.status}`,
|
||||
label: `${e.executionId.slice(0, 12)} — ${statusLabel(e.status)}`,
|
||||
})),
|
||||
[exchangeRows],
|
||||
);
|
||||
|
||||
@@ -20,12 +20,8 @@ export function formatDurationShort(ms: number | undefined): string {
|
||||
}
|
||||
|
||||
export function statusLabel(s: string): string {
|
||||
switch (s) {
|
||||
case 'COMPLETED': return 'OK';
|
||||
case 'FAILED': return 'ERR';
|
||||
case 'RUNNING': return 'RUN';
|
||||
default: return s;
|
||||
}
|
||||
if (!s) return s;
|
||||
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
export function timeAgo(iso?: string): string {
|
||||
|
||||
Reference in New Issue
Block a user