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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user