fix: improve duration formatting (Xm Ys) and truncate exchange IDs

- formatDuration and formatDurationShort now show Xm Ys for durations >= 60s (e.g. "5m 21s" instead of "321s") and 1 decimal for 1-60s range ("6.7s" instead of "6.70s")
- Exchange ID column shows last 8 chars with ellipsis prefix; full ID on hover, copies to clipboard on click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-09 18:34:04 +02:00
parent ba0a1850a9
commit eadcd160a3
14 changed files with 293 additions and 269 deletions

View File

@@ -5,12 +5,8 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
/* pipelineCard — card styling via sectionStyles.section */
.pipelineCard { .pipelineCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px 20px;
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@@ -2,6 +2,7 @@ import { StatCard, DataTable, ProgressBar } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useClickHouseStatus, useClickHouseTables, useClickHousePerformance, useClickHouseQueries, useIndexerPipeline } from '../../api/queries/admin/clickhouse'; import { useClickHouseStatus, useClickHouseTables, useClickHousePerformance, useClickHouseQueries, useIndexerPipeline } from '../../api/queries/admin/clickhouse';
import styles from './ClickHouseAdminPage.module.css'; import styles from './ClickHouseAdminPage.module.css';
import sectionStyles from '../../styles/section-card.module.css';
import tableStyles from '../../styles/table-section.module.css'; import tableStyles from '../../styles/table-section.module.css';
export default function ClickHouseAdminPage() { export default function ClickHouseAdminPage() {
@@ -53,7 +54,7 @@ export default function ClickHouseAdminPage() {
{/* Pipeline */} {/* Pipeline */}
{pipeline && ( {pipeline && (
<div className={styles.pipelineCard}> <div className={`${sectionStyles.section} ${styles.pipelineCard}`}>
<div className={styles.pipelineTitle}>Indexer Pipeline</div> <div className={styles.pipelineTitle}>Indexer Pipeline</div>
<ProgressBar value={pipeline.maxQueueSize > 0 ? (pipeline.queueDepth / pipeline.maxQueueSize) * 100 : 0} /> <ProgressBar value={pipeline.maxQueueSize > 0 ? (pipeline.queueDepth / pipeline.maxQueueSize) * 100 : 0} />
<div className={styles.pipelineMetrics}> <div className={styles.pipelineMetrics}>

View File

@@ -2,6 +2,7 @@ import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner } from '
import type { Column } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system';
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database'; import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
import styles from './DatabaseAdminPage.module.css'; import styles from './DatabaseAdminPage.module.css';
import tableStyles from '../../styles/table-section.module.css';
export default function DatabaseAdminPage() { export default function DatabaseAdminPage() {
const { data: status, isError: statusError } = useDatabaseStatus(); const { data: status, isError: statusError } = useDatabaseStatus();
@@ -54,13 +55,17 @@ export default function DatabaseAdminPage() {
</Card> </Card>
)} )}
<div className={styles.section}> <div className={tableStyles.tableSection}>
<div className={styles.sectionHeading}>Tables</div> <div className={tableStyles.tableHeader}>
<span className={tableStyles.tableTitle}>Tables</span>
</div>
<DataTable columns={tableColumns} data={(tables || []).map((t: any) => ({ ...t, id: t.tableName }))} sortable pageSize={20} /> <DataTable columns={tableColumns} data={(tables || []).map((t: any) => ({ ...t, id: t.tableName }))} sortable pageSize={20} />
</div> </div>
<div className={styles.section}> <div className={tableStyles.tableSection}>
<div className={styles.sectionHeading}>Active Queries</div> <div className={tableStyles.tableHeader}>
<span className={tableStyles.tableTitle}>Active Queries</span>
</div>
<DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} /> <DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} />
</div> </div>
</div> </div>

View File

@@ -25,6 +25,7 @@ import {
} from '../../api/queries/admin/environments'; } from '../../api/queries/admin/environments';
import type { Environment } from '../../api/queries/admin/environments'; import type { Environment } from '../../api/queries/admin/environments';
import styles from './UserManagement.module.css'; import styles from './UserManagement.module.css';
import sectionStyles from '../../styles/section-card.module.css';
function slugify(name: string): string { function slugify(name: string): string {
return name return name
@@ -263,34 +264,38 @@ export default function EnvironmentsPage() {
</span> </span>
</div> </div>
<SectionHeader>Configuration</SectionHeader> <div className={sectionStyles.section}>
<div className={styles.securitySection}> <SectionHeader>Configuration</SectionHeader>
<div className={styles.securityRow}> <div className={styles.securitySection}>
<Toggle checked={selected.production} onChange={() => handleToggleProduction(!selected.production)} /> <div className={styles.securityRow}>
<span>Production environment</span> <Toggle checked={selected.production} onChange={() => handleToggleProduction(!selected.production)} />
{selected.production ? ( <span>Production environment</span>
<Tag label="Dedicated resources" color="error" /> {selected.production ? (
) : ( <Tag label="Dedicated resources" color="error" />
<Tag label="Shared resources" color="auto" /> ) : (
)} <Tag label="Shared resources" color="auto" />
)}
</div>
</div> </div>
</div> </div>
<SectionHeader>Status</SectionHeader> <div className={sectionStyles.section}>
<div className={styles.securitySection}> <SectionHeader>Status</SectionHeader>
<div className={styles.securityRow}> <div className={styles.securitySection}>
<Toggle checked={selected.enabled} onChange={() => handleToggleEnabled(!selected.enabled)} /> <div className={styles.securityRow}>
<span>{selected.enabled ? 'Enabled' : 'Disabled'}</span> <Toggle checked={selected.enabled} onChange={() => handleToggleEnabled(!selected.enabled)} />
<span>{selected.enabled ? 'Enabled' : 'Disabled'}</span>
{!selected.enabled && (
<Tag label="No new deployments" color="warning" />
)}
</div>
{!selected.enabled && ( {!selected.enabled && (
<Tag label="No new deployments" color="warning" /> <p className={styles.inheritedNote}>
Disabled environments do not allow new deployments. Active
deployments can only be started, stopped, or deleted.
</p>
)} )}
</div> </div>
{!selected.enabled && (
<p className={styles.inheritedNote}>
Disabled environments do not allow new deployments. Active
deployments can only be started, stopped, or deleted.
</p>
)}
</div> </div>
<DefaultResourcesSection environment={selected} onSave={async (config) => { <DefaultResourcesSection environment={selected} onSave={async (config) => {
@@ -385,7 +390,7 @@ function DefaultResourcesSection({ environment, onSave, saving }: {
} }
return ( return (
<> <div className={sectionStyles.section}>
<SectionHeader>Default Resource Limits</SectionHeader> <SectionHeader>Default Resource Limits</SectionHeader>
<p className={styles.inheritedNote}> <p className={styles.inheritedNote}>
These defaults apply to new apps in this environment unless overridden per-app. These defaults apply to new apps in this environment unless overridden per-app.
@@ -444,7 +449,7 @@ function DefaultResourcesSection({ environment, onSave, saving }: {
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>Edit Defaults</Button> <Button size="sm" variant="secondary" onClick={() => setEditing(true)}>Edit Defaults</Button>
)} )}
</div> </div>
</> </div>
); );
} }
@@ -478,7 +483,7 @@ function JarRetentionSection({ environment, onSave, saving }: {
} }
return ( return (
<> <div className={sectionStyles.section}>
<SectionHeader>JAR Retention</SectionHeader> <SectionHeader>JAR Retention</SectionHeader>
<p className={styles.inheritedNote}> <p className={styles.inheritedNote}>
Old JAR versions are cleaned up nightly. Currently deployed versions are never deleted. Old JAR versions are cleaned up nightly. Currently deployed versions are never deleted.
@@ -512,6 +517,6 @@ function JarRetentionSection({ environment, onSave, saving }: {
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>Edit Policy</Button> <Button size="sm" variant="secondary" onClick={() => setEditing(true)}>Edit Policy</Button>
)} )}
</div> </div>
</> </div>
); );
} }

View File

@@ -32,6 +32,7 @@ import {
} from '../../api/queries/admin/rbac'; } from '../../api/queries/admin/rbac';
import type { GroupDetail } from '../../api/queries/admin/rbac'; import type { GroupDetail } from '../../api/queries/admin/rbac';
import styles from './UserManagement.module.css'; import styles from './UserManagement.module.css';
import sectionStyles from '../../styles/section-card.module.css';
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010'; const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
@@ -361,69 +362,75 @@ export default function GroupsTab({ highlightId, onHighlightConsumed }: { highli
</> </>
)} )}
<SectionHeader>Members (direct)</SectionHeader> <div className={sectionStyles.section}>
<div className={styles.sectionTags}> <SectionHeader>Members (direct)</SectionHeader>
{members.map((u) => ( <div className={styles.sectionTags}>
<Tag {members.map((u) => (
key={u.userId} <Tag
label={u.displayName} key={u.userId}
color="auto" label={u.displayName}
onRemove={() => handleRemoveMember(u.userId)} color="auto"
onRemove={() => handleRemoveMember(u.userId)}
/>
))}
{members.length === 0 && (
<span className={styles.inheritedNote}>(no members)</span>
)}
<MultiSelect
options={availableMembers}
value={[]}
onChange={handleAddMembers}
placeholder="+ Add"
/> />
))} </div>
{members.length === 0 && ( {children.length > 0 && (
<span className={styles.inheritedNote}>(no members)</span>
)}
<MultiSelect
options={availableMembers}
value={[]}
onChange={handleAddMembers}
placeholder="+ Add"
/>
</div>
{children.length > 0 && (
<span className={styles.inheritedNote}>
+ all members of {children.map((c) => c.name).join(', ')}
</span>
)}
<SectionHeader>Child groups</SectionHeader>
<div className={styles.sectionTags}>
{children.map((c) => (
<Tag key={c.id} label={c.name} color="success" />
))}
{children.length === 0 && (
<span className={styles.inheritedNote}> <span className={styles.inheritedNote}>
(no child groups) + all members of {children.map((c) => c.name).join(', ')}
</span> </span>
)} )}
</div> </div>
<SectionHeader>Assigned roles</SectionHeader> <div className={sectionStyles.section}>
<div className={styles.sectionTags}> <SectionHeader>Child groups</SectionHeader>
{(selectedGroup.directRoles ?? []).map((r) => ( <div className={styles.sectionTags}>
<Tag {children.map((c) => (
key={r.id} <Tag key={c.id} label={c.name} color="success" />
label={r.name} ))}
color="warning" {children.length === 0 && (
onRemove={() => { <span className={styles.inheritedNote}>
if (members.length > 0) { (no child groups)
setRemoveRoleTarget(r.id); </span>
} else { )}
handleRemoveRole(r.id); </div>
} </div>
}}
<div className={sectionStyles.section}>
<SectionHeader>Assigned roles</SectionHeader>
<div className={styles.sectionTags}>
{(selectedGroup.directRoles ?? []).map((r) => (
<Tag
key={r.id}
label={r.name}
color="warning"
onRemove={() => {
if (members.length > 0) {
setRemoveRoleTarget(r.id);
} else {
handleRemoveRole(r.id);
}
}}
/>
))}
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
<MultiSelect
options={availableRoles}
value={[]}
onChange={handleAddRoles}
placeholder="+ Add"
/> />
))} </div>
{(selectedGroup.directRoles ?? []).length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
<MultiSelect
options={availableRoles}
value={[]}
onChange={handleAddRoles}
placeholder="+ Add"
/>
</div> </div>
</> </>
) : null ) : null

View File

@@ -35,6 +35,7 @@ import {
import type { UserDetail } from '../../api/queries/admin/rbac'; import type { UserDetail } from '../../api/queries/admin/rbac';
import { useAuthStore } from '../../auth/auth-store'; import { useAuthStore } from '../../auth/auth-store';
import styles from './UserManagement.module.css'; import styles from './UserManagement.module.css';
import sectionStyles from '../../styles/section-card.module.css';
export default function UsersTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) { export default function UsersTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) {
const { toast } = useToast(); const { toast } = useToast();
@@ -366,24 +367,27 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig
</Button> </Button>
</div> </div>
<SectionHeader>Status</SectionHeader> <div className={sectionStyles.section}>
<div className={styles.sectionTags}> <SectionHeader>Status</SectionHeader>
<Tag label="Active" color="success" /> <div className={styles.sectionTags}>
<Tag label="Active" color="success" />
</div>
<div className={styles.metaGrid}>
<span className={styles.metaLabel}>ID</span>
<MonoText size="xs">{selected.userId}</MonoText>
<span className={styles.metaLabel}>Created</span>
<span className={styles.metaValue}>
{new Date(selected.createdAt).toLocaleDateString()}
</span>
<span className={styles.metaLabel}>Provider</span>
<span className={styles.metaValue}>{selected.provider}</span>
</div>
</div> </div>
<div className={styles.metaGrid}> <div className={sectionStyles.section}>
<span className={styles.metaLabel}>ID</span> <SectionHeader>Security</SectionHeader>
<MonoText size="xs">{selected.userId}</MonoText> <div className={styles.securitySection}>
<span className={styles.metaLabel}>Created</span>
<span className={styles.metaValue}>
{new Date(selected.createdAt).toLocaleDateString()}
</span>
<span className={styles.metaLabel}>Provider</span>
<span className={styles.metaValue}>{selected.provider}</span>
</div>
<SectionHeader>Security</SectionHeader>
<div className={styles.securitySection}>
{selected.provider === 'local' ? ( {selected.provider === 'local' ? (
<> <>
<div className={styles.securityRow}> <div className={styles.securityRow}>
@@ -445,133 +449,138 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig
</InfoCallout> </InfoCallout>
</> </>
)} )}
</div>
</div> </div>
<SectionHeader>Group membership (direct only)</SectionHeader> <div className={sectionStyles.section}>
<div className={styles.sectionTags}> <SectionHeader>Group membership (direct only)</SectionHeader>
{selected.directGroups.map((g) => ( <div className={styles.sectionTags}>
<Tag {selected.directGroups.map((g) => (
key={g.id} <Tag
label={g.name} key={g.id}
color="success" label={g.name}
onRemove={() => { color="success"
removeFromGroup.mutate( onRemove={() => {
{ userId: selected.userId, groupId: g.id }, removeFromGroup.mutate(
{ { userId: selected.userId, groupId: g.id },
onSuccess: () => {
toast({ title: 'Group removed', variant: 'success' }), onSuccess: () =>
onError: () => toast({ title: 'Group removed', variant: 'success' }),
toast({ onError: () =>
title: 'Failed to remove group', toast({
variant: 'error', title: 'Failed to remove group',
duration: 86_400_000, variant: 'error',
}), duration: 86_400_000,
}, }),
); },
}} );
/> }}
))} />
{selected.directGroups.length === 0 && ( ))}
<span className={styles.inheritedNote}>(no groups)</span> {selected.directGroups.length === 0 && (
)} <span className={styles.inheritedNote}>(no groups)</span>
<MultiSelect
options={availableGroups}
value={[]}
onChange={(ids) => {
for (const groupId of ids) {
addToGroup.mutate(
{ userId: selected.userId, groupId },
{
onSuccess: () =>
toast({ title: 'Added to group', variant: 'success' }),
onError: () =>
toast({
title: 'Failed to add group',
variant: 'error',
duration: 86_400_000,
}),
},
);
}
}}
placeholder="+ Add"
/>
</div>
<SectionHeader>
Effective roles (direct + inherited)
</SectionHeader>
<div className={styles.sectionTags}>
{selected.directRoles.map((r) => (
<Tag
key={r.id}
label={r.name}
color="warning"
onRemove={() => {
removeRole.mutate(
{ userId: selected.userId, roleId: r.id },
{
onSuccess: () =>
toast({
title: 'Role removed',
description: r.name,
variant: 'success',
}),
onError: () =>
toast({
title: 'Failed to remove role',
variant: 'error',
duration: 86_400_000,
}),
},
);
}}
/>
))}
{inheritedRoles.map((r) => (
<Badge
key={r.id}
label={`${r.name} ↑ group`}
color="warning"
variant="dashed"
className={styles.inherited}
/>
))}
{selected.directRoles.length === 0 &&
inheritedRoles.length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)} )}
<MultiSelect <MultiSelect
options={availableRoles} options={availableGroups}
value={[]} value={[]}
onChange={(roleIds) => { onChange={(ids) => {
for (const roleId of roleIds) { for (const groupId of ids) {
assignRole.mutate( addToGroup.mutate(
{ userId: selected.userId, roleId }, { userId: selected.userId, groupId },
{ {
onSuccess: () => onSuccess: () =>
toast({ toast({ title: 'Added to group', variant: 'success' }),
title: 'Role assigned', onError: () =>
variant: 'success', toast({
}), title: 'Failed to add group',
onError: () => variant: 'error',
toast({ duration: 86_400_000,
title: 'Failed to assign role', }),
variant: 'error', },
duration: 86_400_000, );
}), }
}, }}
); placeholder="+ Add"
} />
}} </div>
placeholder="+ Add" </div>
/>
<div className={sectionStyles.section}>
<SectionHeader>
Effective roles (direct + inherited)
</SectionHeader>
<div className={styles.sectionTags}>
{selected.directRoles.map((r) => (
<Tag
key={r.id}
label={r.name}
color="warning"
onRemove={() => {
removeRole.mutate(
{ userId: selected.userId, roleId: r.id },
{
onSuccess: () =>
toast({
title: 'Role removed',
description: r.name,
variant: 'success',
}),
onError: () =>
toast({
title: 'Failed to remove role',
variant: 'error',
duration: 86_400_000,
}),
},
);
}}
/>
))}
{inheritedRoles.map((r) => (
<Badge
key={r.id}
label={`${r.name} ↑ group`}
color="warning"
variant="dashed"
className={styles.inherited}
/>
))}
{selected.directRoles.length === 0 &&
inheritedRoles.length === 0 && (
<span className={styles.inheritedNote}>(no roles)</span>
)}
<MultiSelect
options={availableRoles}
value={[]}
onChange={(roleIds) => {
for (const roleId of roleIds) {
assignRole.mutate(
{ userId: selected.userId, roleId },
{
onSuccess: () =>
toast({
title: 'Role assigned',
variant: 'success',
}),
onError: () =>
toast({
title: 'Failed to assign role',
variant: 'error',
duration: 86_400_000,
}),
},
);
}
}}
placeholder="+ Add"
/>
</div>
{inheritedRoles.length > 0 && (
<span className={styles.inheritedNote}>
Roles with are inherited through group membership
</span>
)}
</div> </div>
{inheritedRoles.length > 0 && (
<span className={styles.inheritedNote}>
Roles with are inherited through group membership
</span>
)}
</> </>
) : null ) : null
} }

View File

@@ -8,6 +8,7 @@ import {
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system'; import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
import styles from './AgentHealth.module.css'; import styles from './AgentHealth.module.css';
import sectionStyles from '../../styles/section-card.module.css';
import logStyles from '../../styles/log-panel.module.css'; import logStyles from '../../styles/log-panel.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useApplicationLogs } from '../../api/queries/logs'; import { useApplicationLogs } from '../../api/queries/logs';
@@ -353,7 +354,7 @@ export default function AgentHealth() {
{/* Application config bar */} {/* Application config bar */}
{appId && appConfig && ( {appId && appConfig && (
<div className={styles.configBar}> <div className={`${sectionStyles.section} ${styles.configBar}`}>
{configEditing ? ( {configEditing ? (
<> <>
<div className={styles.configField}> <div className={styles.configField}>
@@ -569,7 +570,7 @@ export default function AgentHealth() {
)} )}
</div> </div>
<div className={styles.eventCard}> <div className={`${sectionStyles.section} ${styles.eventCard}`}>
<div className={styles.eventCardHeader}> <div className={styles.eventCardHeader}>
<span className={styles.sectionTitle}>Timeline</span> <span className={styles.sectionTitle}>Timeline</span>
<div className={logStyles.headerActions}> <div className={logStyles.headerActions}>

View File

@@ -44,12 +44,8 @@
font-family: var(--font-mono); font-family: var(--font-mono);
} }
/* Process info card */ /* Process info card — card styling via sectionStyles.section */
.processCard { .processCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 16px; padding: 16px;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -116,12 +112,8 @@
gap: 14px; gap: 14px;
} }
/* Timeline card */ /* Timeline card — card styling via sectionStyles.section */
.timelineCard { .timelineCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -8,6 +8,7 @@ import {
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import type { FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system'; import type { FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
import styles from './AgentInstance.module.css'; import styles from './AgentInstance.module.css';
import sectionStyles from '../../styles/section-card.module.css';
import logStyles from '../../styles/log-panel.module.css'; import logStyles from '../../styles/log-panel.module.css';
import chartCardStyles from '../../styles/chart-card.module.css'; import chartCardStyles from '../../styles/chart-card.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useAgents, useAgentEvents } from '../../api/queries/agents';
@@ -238,7 +239,7 @@ export default function AgentInstance() {
</div> </div>
{/* Process info card */} {/* Process info card */}
<div className={styles.processCard}> <div className={`${sectionStyles.section} ${styles.processCard}`}>
<SectionHeader>Process Information</SectionHeader> <SectionHeader>Process Information</SectionHeader>
<div className={styles.processGrid}> <div className={styles.processGrid}>
{agent.capabilities?.jvmVersion && ( {agent.capabilities?.jvmVersion && (
@@ -438,7 +439,7 @@ export default function AgentInstance() {
)} )}
</div> </div>
<div className={styles.timelineCard}> <div className={`${sectionStyles.section} ${styles.timelineCard}`}>
<div className={styles.timelineHeader}> <div className={styles.timelineHeader}>
<span className={styles.chartTitle}>Timeline</span> <span className={styles.chartTitle}>Timeline</span>
<div className={logStyles.headerActions}> <div className={logStyles.headerActions}>

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect } from 'react' import React, { useState, useMemo, useCallback, useEffect } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router' import { useParams, useNavigate, useSearchParams } from 'react-router'
import { AlertTriangle, X, Search, Footprints, RotateCcw } from 'lucide-react' import { AlertTriangle, X, Search, Footprints, RotateCcw } from 'lucide-react'
import { import {
@@ -113,7 +113,17 @@ function buildBaseColumns(): Column<Row>[] {
header: 'Exchange ID', header: 'Exchange ID',
sortable: true, sortable: true,
render: (_: unknown, row: Row) => ( render: (_: unknown, row: Row) => (
<MonoText size="xs">{row.executionId}</MonoText> <MonoText
size="xs"
title={row.executionId}
style={{ cursor: 'pointer' }}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(row.executionId);
}}
>
...{row.executionId.slice(-8)}
</MonoText>
), ),
}, },
{ {

View File

@@ -420,7 +420,7 @@ export default function DashboardL2() {
{/* Top 5 Errors — hidden when empty */} {/* Top 5 Errors — hidden when empty */}
{errorRows.length > 0 && ( {errorRows.length > 0 && (
<div className={styles.errorsSection}> <div className={tableStyles.tableSection}>
<div className={tableStyles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={tableStyles.tableTitle}>Top Errors</span> <span className={tableStyles.tableTitle}>Top Errors</span>
<span className={tableStyles.tableMeta}>{errorRows.length} error types</span> <span className={tableStyles.tableMeta}>{errorRows.length} error types</span>

View File

@@ -392,7 +392,7 @@ export default function DashboardL3() {
{/* Process Diagram with Latency Heatmap */} {/* Process Diagram with Latency Heatmap */}
{appId && routeId && ( {appId && routeId && (
<div className={styles.diagramSection}> <div className={`${tableStyles.tableSection} ${styles.diagramHeight}`}>
<ProcessDiagram <ProcessDiagram
application={appId} application={appId}
routeId={routeId} routeId={routeId}
@@ -421,7 +421,7 @@ export default function DashboardL3() {
{/* Top 5 Errors — hidden if empty */} {/* Top 5 Errors — hidden if empty */}
{errorRows.length > 0 && ( {errorRows.length > 0 && (
<div className={styles.errorsSection}> <div className={tableStyles.tableSection}>
<div className={tableStyles.tableHeader}> <div className={tableStyles.tableHeader}>
<span className={tableStyles.tableTitle}>Top 5 Errors</span> <span className={tableStyles.tableTitle}>Top 5 Errors</span>
<Badge label={`${errorRows.length}`} color="error" /> <Badge label={`${errorRows.length}`} color="error" />

View File

@@ -37,13 +37,8 @@
text-decoration: underline; text-decoration: underline;
} }
/* Diagram container */ /* Diagram container — height override; card styling via tableStyles.tableSection */
.diagramSection { .diagramHeight {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
height: 280px; height: 280px;
} }
@@ -90,11 +85,4 @@
font-weight: 600; font-weight: 600;
} }
/* Errors section */ /* errorsSection removed — use tableStyles.tableSection instead */
.errorsSection {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
}

View File

@@ -1,13 +1,22 @@
export function formatDuration(ms: number): string { export function formatDuration(ms: number): string {
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`; if (ms >= 60_000) {
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
return `${ms}ms`; return `${ms}ms`;
} }
export function formatDurationShort(ms: number | undefined): string { export function formatDurationShort(ms: number | undefined): string {
if (ms == null) return '-'; if (ms == null) return '-';
if (ms < 1000) return `${ms}ms`; if (ms >= 60_000) {
return `${(ms / 1000).toFixed(1)}s`; const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
}
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
return `${ms}ms`;
} }
export function statusLabel(s: string): string { export function statusLabel(s: string): string {