From 4ea8bb368a4de0ab10c971e4a24a623fb87aa6bc Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:00:50 +0200 Subject: [PATCH 01/18] Add UX polish design spec with comprehensive audit findings Playwright-driven audit of the live UI (build 69dcce2, 60+ screenshots) covering all pages, CRUD lifecycles, design consistency, and interaction patterns. Spec defines 8 batches of work: critical bugs, layout consistency, interaction consistency, contrast/readability, data formatting, chart fixes, admin polish, and nice-to-have items. Co-Authored-By: Claude Opus 4.6 (1M context) --- audit/admin-lifecycle-findings.md | 303 +++++++++ audit/design-consistency-findings.md | 354 +++++++++++ audit/interaction-patterns-findings.md | 599 ++++++++++++++++++ audit/monitoring-pages-findings.md | 267 ++++++++ .../specs/2026-04-09-ux-polish-design.md | 531 ++++++++++++++++ 5 files changed, 2054 insertions(+) create mode 100644 audit/admin-lifecycle-findings.md create mode 100644 audit/design-consistency-findings.md create mode 100644 audit/interaction-patterns-findings.md create mode 100644 audit/monitoring-pages-findings.md create mode 100644 docs/superpowers/specs/2026-04-09-ux-polish-design.md diff --git a/audit/admin-lifecycle-findings.md b/audit/admin-lifecycle-findings.md new file mode 100644 index 00000000..fa2039d5 --- /dev/null +++ b/audit/admin-lifecycle-findings.md @@ -0,0 +1,303 @@ +# Cameleer3 Admin UI UX Audit + +**Date:** 2026-04-09 +**Auditor:** Claude (automated) +**URL:** https://desktop-fb5vgj9.siegeln.internal/ +**Login:** admin/admin (OIDC-authenticated) + +--- + +## Executive Summary + +The Cameleer3 UI is generally well-built with consistent styling, good information density, and a clear layout. However, there are several **Critical** bugs that prevent core CRUD operations from working, and a few **Important** UX issues that reduce clarity and usability. + +**Critical issues:** 3 +**Important issues:** 7 +**Nice-to-have improvements:** 8 + +--- + +## 1. Users & Roles (`/server/admin/rbac`) + +### What Works Well +- Clean master-detail layout: user list on the left, detail panel on the right +- Summary cards at top (Users: 2, Groups: 1, Roles: 4) provide quick overview +- Tab structure (Users / Groups / Roles) is intuitive +- User detail shows all relevant info: status, ID, created date, provider, password, group membership, effective roles +- Inline role/group management with "+ Add" dropdown and "x" remove buttons +- Search bar for filtering users/groups/roles +- Delete button correctly disabled for the admin user (last-admin guard) +- Group detail shows Top-level, children count, member count, and assigned roles +- Local/OIDC toggle on the user creation form + +### Issues Found + +#### CRITICAL: User creation fails silently in OIDC mode +- **Location:** "+ Add user" button and create user form +- **Details:** When OIDC is enabled, the backend returns HTTP 400 with an **empty response body** when attempting to create a local user. The UI shows a generic "Failed to create user" toast with no explanation. +- **Root Cause:** `UserAdminController.createUser()` line 92-93 returns `ResponseEntity.badRequest().build()` (no body) when `oidcEnabled` is true. +- **Impact:** The UI still shows the "+ Add user" button and the full creation form even though the operation will always fail. Users fill out the form, click Create, and get a useless error. +- **Fix:** Either (a) hide the "+ Add user" button when OIDC is enabled, or (b) show a clear inline message like "Local user creation is disabled when OIDC is enabled", or (c) return a proper error body from the API. +- **Screenshots:** `09-user-create-filled.png`, `10-user-create-result.png` + +#### IMPORTANT: Unicode escape shown literally in role descriptions +- **Location:** Roles tab, role description text +- **Details:** Role descriptions display `\u00b7` literally instead of rendering the middle dot character (middle dot). +- **Example:** "Full administrative access \u00b7 0 assignments" should be "Full administrative access - 0 assignments" +- **Screenshot:** `14-roles-tab.png` + +#### IMPORTANT: No "Confirm password" field in user creation +- **Location:** "+ Add user" form +- **Details:** The form has Username*, Display name, Email, Password* but no password confirmation field. This increases the risk of typos in passwords. + +#### NICE-TO-HAVE: Create button disabled until valid with no inline validation messages +- **Location:** User creation form +- **Details:** The "Create" button is disabled until form is valid, but there are no visible inline error messages explaining what is required. The asterisks on "Username *" and "Password *" help, but there's no indication of password policy requirements (min 12 chars, 3-of-4 character classes). + +#### NICE-TO-HAVE: "Select a user to view details" placeholder +- **Location:** Right panel when no user selected +- **Details:** The placeholder text is fine but could be more visually styled (e.g., centered, with an icon). + +--- + +## 2. Audit Log (`/server/admin/audit`) + +### What Works Well +- Comprehensive filter system: date range (1h/6h/Today/24h/7d/Custom), user filter, category dropdown, action/target search +- Category dropdown includes all relevant categories: INFRA, AUTH, USER_MGMT, CONFIG, RBAC, AGENT +- Custom date range with From/To date pickers +- Table columns: Timestamp, User, Category, Action, Target, Result +- Color-coded result badges (SUCCESS in green, FAILURE in red) +- Shows my failed user creation attempts correctly logged as FAILURE +- Row count indicator ("179 events") with AUTO/MANUAL refresh +- Pagination with configurable rows per page + +### Issues Found + +#### IMPORTANT: No export functionality +- **Location:** Audit log page +- **Details:** There is no Export/Download button for audit log data. Compliance requirements typically mandate the ability to export audit logs as CSV or JSON. + +#### NICE-TO-HAVE: Audit detail row expansion +- **Location:** Table rows are clickable (cursor: pointer) but clicking doesn't reveal additional details +- **Details:** For entries like "HTTP POST /api/v1/admin/users FAILURE", it would be helpful to see the error response body or request details in an expanded row. + +#### NICE-TO-HAVE: Date range filter is independent of the global time selector +- **Location:** Top bar time selector vs. audit log's own time filter +- **Details:** The audit log has its own "Last 1h / 6h / Today / 24h / 7d / Custom" filter, which is separate from the global time range in the header bar. While this provides independence, it could confuse users who expect the global time selector to affect the audit log. + +--- + +## 3. OIDC Config (`/server/admin/oidc`) + +### What Works Well +- Well-organized sections: Behavior, Provider Settings, Claim Mapping, Default Roles, Danger Zone +- Each field has a descriptive label and help text (e.g., "RFC 8707 resource indicator sent in the authorization request") +- "Test Connection" button at the top for verification +- "Save" button is clearly visible +- **Excellent** delete protection: "Confirm Deletion" dialog requires typing "delete oidc" to confirm, warns that "All users signed in via OIDC will lose access" +- Enabled/Auto Sign-Up checkboxes with clear descriptions +- Default Roles management with add/remove + +### Issues Found + +#### IMPORTANT: No unsaved changes indicator +- **Location:** Form fields +- **Details:** If a user modifies a field but navigates away without saving, there is no "You have unsaved changes" warning. This is particularly dangerous for the OIDC configuration since changes could lock users out. + +#### NICE-TO-HAVE: Client Secret field is plain text +- **Location:** Client Secret textbox +- **Details:** The Client Secret is a regular text input, not a password/masked field. Since it's sensitive, it should be masked by default with a "show/hide" toggle. + +--- + +## 4. Environments (`/server/admin/environments`) + +### What Works Well +- Clean list with search and "+ Add environment" button +- Master-detail layout consistent with Users & Roles +- Environment detail shows: ID, Tier badge (NON-PROD), slug, created date +- Sub-tabs for "Production environment" and "Docker Containers" +- Default Resource Limits section with configurable values +- JAR Retention section with "Edit Policy" button +- "Edit Defaults" button for container defaults + +### Issues Found + +#### NICE-TO-HAVE: Slug is shown but not labeled clearly +- **Location:** Environment detail panel +- **Details:** The slug "default" appears below the display name "Default" but could benefit from a "Slug:" label for clarity. + +--- + +## 5. Database (`/server/admin/database`) + +### What Works Well +- Clear "Connected" status at the top with green styling +- Shows PostgreSQL version string: "PostgreSQL 16.13 on x86_64-pc-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit" +- Connection Pool section with Active/Idle/Max counts +- Tables section listing all database tables with rows and sizes +- Consistent styling with the rest of the admin section + +### Issues Found + +No significant issues found. The page is read-only and informational, which is appropriate. + +--- + +## 6. ClickHouse (`/server/admin/clickhouse`) + +### What Works Well +- Clear "Connected" status with version number (26.3.5.12) +- Uptime display: "1 hour, 44 minutes and 29 seconds" +- Key metrics: Disk Usage (156.33 MiB), Memory (1.47 GiB), Compression Ratio (0.104x), Rows (4,875,598), Parts (55), Uncompressed Size (424.02 MiB) +- Tables section listing all ClickHouse tables with engine, rows, and sizes +- Consistent card-based layout + +### Issues Found + +No significant issues found. Well-presented status page. + +--- + +## 7. Deployments Tab (`/server/apps`) + +### What Works Well +- Table layout showing app name, environment, status, and created date +- "+ Create App" button clearly visible +- Clicking an app navigates to a detail page with Configuration and Overrides tabs +- Configuration has sub-tabs: Monitoring, Variables, Traces & Taps, Route Recording +- App detail shows environment (DEFAULT), tier (ORACLE), status +- "Create App" full page form with clear Identity & Security, Configuration sections + +### Issues Found + +#### CRITICAL: Direct URL /server/deployments returns 404 error +- **Location:** `/server/deployments` URL +- **Details:** Navigating directly to `/server/deployments` shows "Unexpected Application Error! 404 Not Found" with a React Router development error ("Hey developer -- You can provide a way better UX than this..."). The Deployments tab is actually at `/server/apps`. +- **Impact:** Users who bookmark or share the URL will see an unhandled error page instead of a redirect to the correct URL. +- **Screenshot:** `20-deployments-tab.png` (first attempt) + +#### IMPORTANT: Create App page shows full configuration before app exists +- **Location:** `/server/apps/new` +- **Details:** The Create Application page shows Monitoring configuration, Variables, Traces & Taps, and Route Recording sub-tabs with values already populated. This is overwhelming for initial creation -- a simpler wizard-style flow (name + environment first, then configure) would be more intuitive. + +#### NICE-TO-HAVE: App deletion flow not easily discoverable +- **Location:** App detail page +- **Details:** There is no visible "Delete App" button on the app detail page. The deletion mechanism is not apparent. + +--- + +## 8. SaaS Platform Pages + +### Platform Dashboard (`/platform`) + +#### What Works Well +- Clean tenant overview: "Example Tenant" with LOW tier badge +- Three summary cards: Tier (LOW), Status (ACTIVE), License (Active, expires 8.4.2027) +- Tenant Information section with Slug, Status, Created date +- Server Management section with "Open Server Dashboard" button +- Sidebar navigation: Dashboard, License, Open Server Dashboard + +#### Issues Found + +##### IMPORTANT: "Slug" label missing space +- **Location:** Tenant Information section +- **Details:** Shows "Slugdefault" instead of "Slug: default" -- the label and value run together without separation. + +##### NICE-TO-HAVE: "Open Server Dashboard" button appears 3 times +- **Location:** Page header, Server Management section, sidebar bottom +- **Details:** The same action appears in three places on a single page view. One prominent button would suffice. + +### Platform License (`/platform/license`) + +#### What Works Well +- Clear Validity section: Issued, Expires, Days remaining (365 days badge) +- Features section with Enabled/Disabled badges for each feature +- Limits section: Max Agents, Retention Days, Max Environments +- License Token section with "Show token" button for security + +#### Issues Found + +##### IMPORTANT: Labels and values lack spacing +- **Location:** Validity section, Limits section +- **Details:** "Issued8. April 2026" and "Max Agents3" -- labels and values run together without separators. Should be "Issued: 8. April 2026" and "Max Agents: 3". +- **Screenshot:** `02-platform-license.png` + +--- + +## 9. Cross-Cutting UX Issues + +### CRITICAL: Sporadic auto-navigation to /server/exchanges +- **Location:** Occurs across all admin pages +- **Details:** While interacting with admin pages (Users & Roles, Environments, etc.), the browser occasionally auto-navigates back to `/server/exchanges`. This appears to be triggered by the real-time exchange data stream (SSE). Even when auto-refresh is set to MANUAL, the exchange list continues updating and can cause route changes. +- **Impact:** Users actively editing admin forms can lose their work mid-interaction. This was observed repeatedly during the audit. +- **Root Cause:** Likely a React state update from the SSE exchange stream that triggers a route navigation when the exchange list data changes. + +### IMPORTANT: Error toast messages lack detail +- **Location:** Global toast system +- **Details:** Error toasts show generic messages like "Failed to create user" without the specific API error reason. The server returns empty 400 bodies in some cases, and even when it returns error details, they may not be surfaced in the toast. + +### NICE-TO-HAVE: Global time range selector persists on admin pages +- **Location:** Top header bar on admin pages (Audit Log, ClickHouse, Database, OIDC, etc.) +- **Details:** The global time range selector (1h/3h/6h/Today/24h/7d) and the status filter buttons (OK/Warn/Error/Running) appear on every page including admin pages where they are not relevant. This adds visual clutter. + +### NICE-TO-HAVE: Environment dropdown in header on admin pages +- **Location:** Top header bar, "All Envs" dropdown +- **Details:** The environment selector appears on admin pages where it has no effect (e.g., Users & Roles, OIDC config). It should be hidden or grayed out on pages where it's not applicable. + +--- + +## Summary Table + +| # | Severity | Page | Issue | +|---|----------|------|-------| +| 1 | **CRITICAL** | Users & Roles | User creation fails silently in OIDC mode -- form shown but always returns 400 with empty body | +| 2 | **CRITICAL** | Deployments | Direct URL `/server/deployments` returns unhandled 404 error page | +| 3 | **CRITICAL** | Cross-cutting | Sporadic auto-navigation to `/server/exchanges` interrupts admin page interactions | +| 4 | **IMPORTANT** | Users & Roles | Unicode escape `\u00b7` shown literally in role descriptions | +| 5 | **IMPORTANT** | Users & Roles | No password confirmation field in user creation form | +| 6 | **IMPORTANT** | Audit Log | No export/download functionality for compliance | +| 7 | **IMPORTANT** | OIDC | No unsaved changes warning on form navigation | +| 8 | **IMPORTANT** | Deployments | Create App page shows all config options before app exists (overwhelming) | +| 9 | **IMPORTANT** | Platform Dashboard | Label-value spacing missing ("Slugdefault", "Issued8. April 2026", "Max Agents3") | +| 10 | **IMPORTANT** | Cross-cutting | Error toasts lack specific error details from API responses | +| 11 | Nice-to-have | Users & Roles | No inline validation messages on creation form (just disabled button) | +| 12 | Nice-to-have | Users & Roles | "Select a user to view details" placeholder could be more visual | +| 13 | Nice-to-have | Audit Log | Clickable rows don't expand to show additional event detail | +| 14 | Nice-to-have | Audit Log | Separate time filter from global time selector could confuse users | +| 15 | Nice-to-have | OIDC | Client Secret field should be masked by default | +| 16 | Nice-to-have | Environments | Slug display could use explicit label | +| 17 | Nice-to-have | Deployments | Delete app flow not easily discoverable | +| 18 | Nice-to-have | Cross-cutting | Global time range and status filter buttons shown on irrelevant admin pages | + +--- + +## Screenshots Index + +| File | Description | +|------|-------------| +| `01-platform-dashboard.png` | SaaS Platform dashboard | +| `02-platform-license.png` | License page with features and limits | +| `03-server-exchanges-overview.png` | Server exchanges main view | +| `05-users-roles-page.png` | Users & Roles list view | +| `06-user-detail-admin.png` | Admin user detail panel | +| `07-add-user-dialog.png` | Add user form (showing along with detail) | +| `09-user-create-filled.png` | User creation form filled out | +| `10-user-create-result.png` | Error toast after failed user creation | +| `11-rbac-after-create.png` | RBAC page after failed creation (still 2 users) | +| `13-groups-tab.png` | Groups tab with Admins group | +| `14-roles-tab.png` | Roles tab showing unicode escape bug | +| `15-audit-log.png` | Audit log with failed user creation events | +| `16-clickhouse.png` | ClickHouse status page | +| `17-database.png` | Database status page | +| `18-environments.png` | Environments list | +| `19-oidc.png` | OIDC configuration page | +| `19-oidc-full.png` | OIDC full page (scrolled) | +| `20-deployments-tab.png` | Deployments tab (via tab click) | +| `21-environment-detail.png` | Default environment detail | +| `22-create-app.png` | Create Application form | +| `23-app-detail.png` | Sample app detail page | +| `24-runtime-tab.png` | Runtime tab with agents | +| `25-dashboard-tab.png` | Dashboard with metrics and charts | +| `26-oidc-delete-confirm.png` | OIDC delete confirmation dialog (well done) | diff --git a/audit/design-consistency-findings.md b/audit/design-consistency-findings.md new file mode 100644 index 00000000..fd616bec --- /dev/null +++ b/audit/design-consistency-findings.md @@ -0,0 +1,354 @@ +# Design Consistency Audit — Cameleer3 UI + +**Audited**: 2026-04-09 +**Scope**: All pages under `ui/src/pages/` +**Base path**: `C:/Users/Hendrik/Documents/projects/cameleer3-server/ui/src/` + + +## Shared Layout Infrastructure + +### LayoutShell (`components/LayoutShell.tsx`) +All pages render inside `
` which applies: +```css +.mainContent { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} +``` +This is a flex column container with **no padding/margin**. Each page is responsible for its own content spacing. + +### Shared CSS Modules (`styles/`) +| Module | Class | Pattern | +|--------|-------|---------| +| `section-card.module.css` | `.section` | Card with `padding: 16px 20px`, border, shadow, `margin-bottom: 16px` | +| `table-section.module.css` | `.tableSection` | Card wrapper for tables, no padding (overflow hidden), with `.tableHeader` (12px 16px padding) | +| `chart-card.module.css` | `.chartCard` | Card with `padding: 16px` | +| `log-panel.module.css` | `.logCard` | Card for log viewers, max-height 420px | +| `refresh-indicator.module.css` | `.refreshIndicator` | Auto-refresh dot indicator | +| `rate-colors.module.css` | `.rateGood/.rateWarn/.rateBad` | Semantic color helpers | + + +## Per-Page Findings + +--- + +### 1. Exchanges Page (`pages/Exchanges/`) + +**Files**: `ExchangesPage.tsx`, `ExchangesPage.module.css`, `ExchangeHeader.tsx`, `ExchangeHeader.module.css`, `RouteControlBar.tsx`, `RouteControlBar.module.css` + +**Container pattern**: NO wrapper padding. Uses `height: 100%` split-view layout that fills the entire `mainContent` area. + +**Content wrapper**: +```css +.splitView { display: flex; height: 100%; overflow: hidden; } +``` + +**Table**: The exchange list is rendered by `Dashboard.tsx` (in `pages/Dashboard/`), which uses: +```css +.content { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; background: var(--bg-body); } +``` +- Custom `.tableHeader` with `padding: 8px 12px` (slightly tighter than shared `tableStyles.tableHeader` which uses `12px 16px`) +- `DataTable` rendered with `flush` and `fillHeight` props +- **NO card wrapper** around the table — it's full-bleed against the background +- **Does NOT import shared `table-section.module.css`** — rolls its own `.tableHeader`, `.tableTitle`, `.tableRight`, `.tableMeta` + +**Shared modules used**: NONE. All custom. + +**INCONSISTENCY**: Full-bleed table with no card, no container padding. Custom table header styling duplicates shared module patterns with slightly different padding values (8px 12px vs 12px 16px). + +--- + +### 2. Dashboard Tab (`pages/DashboardTab/`) + +**Files**: `DashboardPage.tsx`, `DashboardL1.tsx`, `DashboardL2.tsx`, `DashboardL3.tsx`, `DashboardTab.module.css` + +**Container pattern**: +```css +.content { display: flex; flex-direction: column; gap: 20px; flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 20px; } +``` +- **No top/left/right padding** — content is full-width inside `mainContent` +- Only `padding-bottom: 20px` and `gap: 20px` between sections + +**Tables**: Wrapped in shared `tableStyles.tableSection` (card with border, shadow, border-radius). Imports `table-section.module.css`. + +**Charts**: Wrapped in design-system `` component. + +**Custom sections**: `errorsSection` and `diagramSection` duplicate the card pattern: +```css +.errorsSection { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; +} +``` +This is identical to `tableStyles.tableSection` but defined separately in `DashboardTab.module.css`. + +**Shared modules used**: `table-section.module.css`, `refresh-indicator.module.css`, `rate-colors.module.css` + +**INCONSISTENCY**: No container padding means KPI strip and tables sit flush against the sidebar/edge. The `.errorsSection` duplicates `tableStyles.tableSection` exactly — should import the shared module instead of copy-pasting. + +--- + +### 3. Runtime Tab — Agent Health (`pages/AgentHealth/`) + +**Files**: `AgentHealth.tsx`, `AgentHealth.module.css` + +**Container pattern**: +```css +.content { flex: 1; overflow-y: auto; padding: 20px 24px 40px; min-width: 0; background: var(--bg-body); } +``` +- **Has explicit padding**: `20px 24px 40px` (top, sides, bottom) + +**Tables**: Uses design-system `DataTable` inside a DS `Card` component for agent group cards. The group cards use custom `.groupGrid` grid layout. No `tableStyles.tableSection` wrapper. + +**Cards/sections**: Custom card patterns like `.configBar`, `.eventCard`: +```css +.configBar { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + padding: 12px 16px; + margin-bottom: 16px; +} +``` + +**Shared modules used**: `log-panel.module.css` + +**INCONSISTENCY**: Uses `padding: 20px 24px 40px` — different from DashboardTab (no padding) and Exchanges (no padding). Custom card patterns duplicate the standard card styling. Does not use `table-section.module.css` or `section-card.module.css`. + +--- + +### 4. Runtime Tab — Agent Instance (`pages/AgentInstance/`) + +**Files**: `AgentInstance.tsx`, `AgentInstance.module.css` + +**Container pattern**: +```css +.content { flex: 1; overflow-y: auto; padding: 20px 24px 40px; min-width: 0; background: var(--bg-body); } +``` +- Matches AgentHealth padding exactly (consistent within Runtime tab) + +**Cards/sections**: Custom `.processCard`, `.timelineCard` duplicate the card pattern. Uses `chart-card.module.css` for chart wrappers. + +**Shared modules used**: `log-panel.module.css`, `chart-card.module.css` + +**INCONSISTENCY**: Consistent with AgentHealth but inconsistent with DashboardTab and Exchanges. Custom card patterns (processCard, timelineCard) duplicate shared module patterns. + +--- + +### 5. Apps Tab (`pages/AppsTab/`) + +**Files**: `AppsTab.tsx`, `AppsTab.module.css` + +**Container pattern**: +```css +.container { padding: 16px; overflow-y: auto; flex: 1; } +``` +- **Has padding**: `16px` all around + +**Content structure**: Three sub-views (`AppListView`, `AppDetailView`, `CreateAppView`) all wrapped in `.container`. + +**Tables**: App list uses `DataTable` directly — no `tableStyles.tableSection` wrapper. Deployment table uses custom `.table` with manual `` HTML (not DataTable). + +**Form controls**: Directly on page background with custom grid layout (`.configGrid`). Uses `SectionHeader` from design-system for visual grouping, but forms are not in cards/sections — they sit flat against the `.container` background. + +**Custom elements**: +- `.editBanner` / `.editBannerActive` — custom banner pattern +- `.configGrid` — 2-column label/input grid +- `.table` — fully custom `
` styling (not DataTable) + +**Shared modules used**: NONE. All custom. + +**INCONSISTENCY (user-reported)**: Controls "meshed into background" — correct. Form controls use `SectionHeader` for labels but no `section-card` wrapper. The Tabs component provides visual grouping but the content below tabs is flat. Config grids, toggles, and inputs sit directly on `var(--bg-body)` background via the 16px-padded container. No card/section separation between different config groups. Also uses a manual `
` element instead of DataTable for deployments. + +--- + +### 6. Admin — RBAC Page (`pages/Admin/RbacPage.tsx`, `UsersTab.tsx`, `GroupsTab.tsx`, `RolesTab.tsx`) + +**Container pattern**: AdminLayout provides `padding: 20px 24px 40px`. RbacPage renders a bare `
` (no extra wrapper class). + +**Content**: Uses `StatCard` strip, `Tabs`, then tab content. Detail views use `SplitPane` (from design-system). User/Group/Role detail sections use `SectionHeader` without card wrappers. + +**Stat strip**: Custom grid — `grid-template-columns: repeat(3, 1fr)` with `gap: 10px; margin-bottom: 16px` + +**Shared modules used**: NONE. Uses `UserManagement.module.css` (custom). + +**INCONSISTENCY**: Detail sections use `SectionHeader` labels but content is flat (no `section-card` wrapper). Similar to AppsTab pattern. + +--- + +### 7. Admin — Audit Log (`pages/Admin/AuditLogPage.tsx`) + +**Container pattern**: Inherits AdminLayout padding (`20px 24px 40px`). Renders a bare `
`. + +**Table**: Properly uses shared `tableStyles.tableSection` with `.tableHeader`, `.tableTitle`, `.tableRight`, `.tableMeta`. + +**Shared modules used**: `table-section.module.css` + +**STATUS**: CONSISTENT with shared patterns for the table section. Good. + +--- + +### 8. Admin — OIDC Config (`pages/Admin/OidcConfigPage.tsx`) + +**Container pattern**: Inherits AdminLayout padding. Adds `.page { max-width: 640px; margin: 0 auto; }` — centered narrow layout. + +**Sections**: Uses shared `sectionStyles.section` from `section-card.module.css` for every form group. Uses `SectionHeader` inside each section card. + +**Shared modules used**: `section-card.module.css` + +**STATUS**: GOOD. This is the correct pattern — form groups wrapped in section cards. Should be the model for other form pages. + +--- + +### 9. Admin — Database (`pages/Admin/DatabaseAdminPage.tsx`) + +**Container pattern**: Inherits AdminLayout padding. Renders bare `
`. + +**Tables**: Uses `DataTable` directly with NO `tableStyles.tableSection` wrapper. Tables under custom `.section` divs with `.sectionHeading` text labels. + +**Cards**: Uses DS `` for connection pool. Stat strip is a flex layout. + +**Shared modules used**: NONE. All custom. + +**INCONSISTENCY**: Tables not wrapped in `tableStyles.tableSection`. Uses custom section headings instead of `SectionHeader`. Missing card wrappers around tables. Stat strip uses `flex` layout while other pages use `grid`. + +--- + +### 10. Admin — ClickHouse (`pages/Admin/ClickHouseAdminPage.tsx`) + +**Container pattern**: Inherits AdminLayout padding. Renders bare `
`. + +**Tables**: Uses shared `tableStyles.tableSection` combined with custom `.tableSection` for margin: `className={tableStyles.tableSection} ${styles.tableSection}`. + +**Custom elements**: `.pipelineCard` duplicates card pattern (bg-surface, border, radius, shadow, padding). + +**Shared modules used**: `table-section.module.css` + +**PARTIAL**: Tables correctly use shared module. Pipeline card duplicates shared card pattern. + +--- + +### 11. Admin — Environments (`pages/Admin/EnvironmentsPage.tsx`) + +**Container pattern**: Inherits AdminLayout padding. Renders via `SplitPane` (design-system). + +**Content**: Uses `SectionHeader`, `SplitPane`, custom meta grids from `UserManagement.module.css`. + +**Shared modules used**: Uses `UserManagement.module.css` (shared with RBAC pages) + +**INCONSISTENCY**: Does not use `section-card.module.css` for form sections. Config sections use `SectionHeader` without card wrappers. `SplitPane` provides some structure but detail content is flat. + +--- + +### 12. Admin — App Config Detail (`pages/Admin/AppConfigDetailPage.tsx`) + +**Container pattern**: Adds `.page { max-width: 720px; margin: 0 auto; }` — centered layout. + +**Sections**: Uses shared `sectionStyles.section` from `section-card.module.css`. Uses `SectionHeader` inside section cards. Custom header card duplicates the card pattern. + +**Shared modules used**: `section-card.module.css` + +**STATUS**: GOOD. Follows same pattern as OIDC page. + +--- + +### 13. Routes pages (`pages/Routes/`) — NOT ROUTED + +These pages (`RoutesMetrics.tsx`, `RouteDetail.tsx`) exist but are NOT in `router.tsx`. They may be deprecated or used as sub-components. `RoutesMetrics` correctly uses shared `tableStyles.tableSection`. `RouteDetail` has many custom card patterns (`.headerCard`, `.diagramPane`, `.statsPane`, `.executionsTable`, `.routeFlowSection`) that duplicate the shared card pattern. + +--- + + +## Summary: Inconsistency Matrix + +### Container Padding + +| Page | Padding | Pattern | +|------|---------|---------| +| **Exchanges** | NONE (full-bleed) | `height: 100%`, fills container | +| **Dashboard Tab** | NONE (gap only) | `gap: 20px`, `padding-bottom: 20px` only | +| **Runtime (AgentHealth)** | `20px 24px 40px` | Explicit padding | +| **Runtime (AgentInstance)** | `20px 24px 40px` | Explicit padding | +| **Apps Tab** | `16px` | Uniform padding | +| **Admin pages** | `20px 24px 40px` | Via AdminLayout | + +**Finding**: Three different padding strategies. Exchanges and Dashboard have no padding; Runtime and Admin use 20px/24px; Apps uses 16px. + + +### Table Wrapper Pattern + +| Page | Uses `tableStyles.tableSection`? | Card wrapper? | +|------|----------------------------------|---------------| +| **Exchanges (Dashboard.tsx)** | NO — custom `.tableHeader` | NO — full-bleed | +| **Dashboard L1/L2/L3** | YES | YES (shared) | +| **Runtime AgentHealth** | NO | YES (via DS `Card`) | +| **Apps Tab** | NO | NO — bare `
` | +| **Admin — Audit** | YES | YES (shared) | +| **Admin — ClickHouse** | YES | YES (shared) | +| **Admin — Database** | NO | NO | + +**Finding**: 4 of 7 table-using pages do NOT use the shared `table-section.module.css`. The Exchanges page custom header has padding `8px 12px` vs shared `12px 16px`. + + +### Form/Control Wrapper Pattern + +| Page | Form controls in cards? | Uses `section-card`? | +|------|------------------------|---------------------| +| **Apps Tab (detail)** | NO — flat against background | NO | +| **Apps Tab (create)** | NO — flat against background | NO | +| **Admin — OIDC** | YES | YES | +| **Admin — App Config** | YES | YES | +| **Admin — RBAC detail** | NO — flat against background | NO | +| **Admin — Environments** | NO — flat against background | NO | +| **Admin — Database** | PARTIAL (Card for pool) | NO | +| **Runtime — AgentHealth** | YES (custom `.configBar`) | NO (custom) | + +**Finding**: Only OIDC and AppConfigDetail use `section-card.module.css` for form grouping. Most form pages render controls flat against the page background. + + +### Duplicated Card Pattern + +The following CSS pattern appears in 8+ custom locations instead of importing `section-card.module.css` or `table-section.module.css`: + +```css +background: var(--bg-surface); +border: 1px solid var(--border-subtle); +border-radius: var(--radius-lg); +box-shadow: var(--shadow-card); +``` + +**Duplicated in**: +- `DashboardTab.module.css` → `.errorsSection`, `.diagramSection` +- `AgentHealth.module.css` → `.configBar`, `.eventCard` +- `AgentInstance.module.css` → `.processCard`, `.timelineCard` +- `ClickHouseAdminPage.module.css` → `.pipelineCard` +- `AppConfigDetailPage.module.css` → `.header` +- `RouteDetail.module.css` → `.headerCard`, `.diagramPane`, `.statsPane`, `.executionsTable`, `.routeFlowSection` + + +## Prioritized Fixes + +### P0 — User-reported issues +1. **Exchanges table full-bleed**: `Dashboard.tsx` should wrap its table in `tableStyles.tableSection` and use the shared table header classes instead of custom ones. Custom `.tableHeader` padding (8px 12px) should match shared (12px 16px). +2. **Apps detail flat controls**: `AppsTab.tsx` config sections should wrap form groups in `sectionStyles.section` (from `section-card.module.css`), matching the OIDC page pattern. +3. **Apps deployment table**: Replace manual `
` with `DataTable` inside `tableStyles.tableSection`. + +### P1 — Padding normalization +4. **Standardize container padding**: Choose ONE pattern for scrollable content areas. Recommended: `padding: 20px 24px 40px` (currently used by Runtime + Admin). Apply to DashboardTab's `.content`. Exchanges is an exception due to its split-view height-filling layout. +5. **DashboardTab.module.css**: Add side padding to `.content`. + +### P2 — Shared module adoption +6. **Replace duplicated card patterns**: Import `section-card.module.css` or `table-section.module.css` instead of duplicating the card CSS in: + - `DashboardTab.module.css` (`.errorsSection` -> use `tableStyles.tableSection`) + - `AgentHealth.module.css` (`.configBar`, `.eventCard`) + - `AgentInstance.module.css` (`.processCard`, `.timelineCard`) + - `ClickHouseAdminPage.module.css` (`.pipelineCard`) +7. **Database admin**: Wrap tables in `tableStyles.tableSection`. +8. **Admin detail pages** (RBAC, Environments): Wrap form sections in `sectionStyles.section`. diff --git a/audit/interaction-patterns-findings.md b/audit/interaction-patterns-findings.md new file mode 100644 index 00000000..390f09be --- /dev/null +++ b/audit/interaction-patterns-findings.md @@ -0,0 +1,599 @@ +# Cameleer3 UI Interaction Patterns Audit + +Audit date: 2026-04-09 +Scope: All `.tsx` files under `ui/src/pages/` and `ui/src/components/` + +--- + +## 1. Delete / Destructive Operations + +### 1.1 Delete User +- **File**: `ui/src/pages/Admin/UsersTab.tsx` (lines 155-172, 358-365, 580-587) +- **Button location**: Detail pane header, top-right, inline with avatar and name +- **Button**: `` +- **Confirmation**: `ConfirmDialog` (type-to-confirm) + - Message: `Delete user "${name}"? This cannot be undone.` + - Confirm text: user's `displayName` + - Has `loading` prop bound to mutation +- **Self-delete guard**: Button is `disabled={isSelf}` (cannot delete yourself) +- **Toast on success**: `variant: 'warning'`, title: "User deleted" +- **Toast on error**: `variant: 'error'`, `duration: 86_400_000` + +### 1.2 Remove User From Group (via User detail) +- **File**: `ui/src/pages/Admin/UsersTab.tsx` (lines 588-613) +- **Button location**: Tag `onRemove` handler on group tags in detail pane +- **Confirmation**: `AlertDialog` (simple confirm, no type-to-confirm) + - Title: "Remove group membership" + - Description: "Removing this group may also revoke inherited roles. Continue?" + - Confirm label: "Remove" + - Variant: `warning` +- **Toast on success**: `variant: 'success'`, title: "Group removed" + +### 1.3 Remove Role From User (via User detail) +- **File**: `ui/src/pages/Admin/UsersTab.tsx` (lines 504-528) +- **Button location**: Tag `onRemove` handler on role tags in detail pane +- **Confirmation**: NONE -- immediate mutation on tag remove click +- **Toast on success**: `variant: 'success'`, title: "Role removed" + +**INCONSISTENCY**: Removing a group shows an AlertDialog confirmation but removing a role does not, even though both can have cascading effects. + +### 1.4 Delete Group +- **File**: `ui/src/pages/Admin/GroupsTab.tsx` (lines 140-155, 340-347, 434-441) +- **Button location**: Detail pane header, top-right +- **Button**: `` +- **Confirmation**: `ConfirmDialog` (type-to-confirm) + - Message: `Delete group "${name}"? This cannot be undone.` + - Confirm text: group's `name` + - Has `loading` prop +- **Built-in guard**: Button is `disabled={isBuiltinAdmins}` +- **Toast on success**: `variant: 'warning'`, title: "Group deleted" + +### 1.5 Remove Role From Group +- **File**: `ui/src/pages/Admin/GroupsTab.tsx` (lines 404-427, 442-455) +- **Button location**: Tag `onRemove` handler on role tags in group detail +- **Confirmation**: `AlertDialog` shown ONLY when the group has members (conditional) + - Title: "Remove role from group" + - Description: `Removing this role will affect ${members.length} member(s) who inherit it. Continue?` + - Confirm label: "Remove" + - Variant: `warning` +- **If group has no members**: Immediate mutation, no confirmation +- **Toast on success**: `variant: 'success'`, title: "Role removed" + +### 1.6 Remove Member From Group +- **File**: `ui/src/pages/Admin/GroupsTab.tsx` (lines 366-372) +- **Button location**: Tag `onRemove` handler on member tags in group detail +- **Confirmation**: NONE -- immediate mutation on tag remove click +- **Toast on success**: `variant: 'success'`, title: "Member removed" + +### 1.7 Delete Role +- **File**: `ui/src/pages/Admin/RolesTab.tsx` (lines 93-110, 261-265, 223-231) +- **Button location**: Detail pane header, top-right +- **Button**: `` +- **Confirmation**: `ConfirmDialog` (type-to-confirm) + - Message: `Delete role "${name}"? This cannot be undone.` + - Confirm text: role's `name` + - Has `loading` prop +- **System role guard**: Button hidden for system roles (`!role.system`) +- **Toast on success**: `variant: 'warning'`, title: "Role deleted" + +### 1.8 Delete Environment +- **File**: `ui/src/pages/Admin/EnvironmentsPage.tsx` (lines 101-112, 245-252, 319-327) +- **Button location**: Detail pane header, top-right +- **Button**: `` +- **Confirmation**: `ConfirmDialog` (type-to-confirm) + - Message: `Delete environment "${displayName}"? All apps and deployments in this environment will be removed. This cannot be undone.` + - Confirm text: environment's `slug` (NOT the display name) + - Has `loading` prop +- **Default guard**: Button is `disabled={isDefault}` (cannot delete default environment) +- **Toast on success**: `variant: 'warning'`, title: "Environment deleted" + +**NOTE**: The confirm text requires the slug but the message shows the display name. This is intentional (slug is the unique identifier) but differs from Users/Groups/Roles which use the display name. + +### 1.9 Delete OIDC Configuration +- **File**: `ui/src/pages/Admin/OidcConfigPage.tsx` (lines 113-124, 253-264) +- **Button location**: Bottom of page in a "Danger Zone" section +- **Button**: `` +- **Confirmation**: `ConfirmDialog` (type-to-confirm) + - Message: `Delete OIDC configuration? All users signed in via OIDC will lose access.` + - Confirm text: `"delete oidc"` (static string) + - NO `loading` prop +- **Toast on success**: `variant: 'warning'`, title: "Configuration deleted" + +**INCONSISTENCY**: No `loading` prop on this ConfirmDialog, unlike all other delete confirmations. + +### 1.10 Delete App +- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 533-539, 565, 589-596) +- **Button location**: App detail header, top-right, in `detailActions` div alongside "Upload JAR" +- **Button**: `` +- **Confirmation**: `ConfirmDialog` (type-to-confirm) + - Message: `Delete app "${displayName}"? All versions and deployments will be removed. This cannot be undone.` + - Confirm text: app's `slug` + - Has `loading` prop +- **Toast on success**: `variant: 'warning'`, title: "App deleted" +- **Post-delete**: Navigates to `/apps` + +### 1.11 Stop Deployment +- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 526-531, 672) +- **Button location**: Inline in deployments table, right-aligned actions column +- **Button**: `` +- **Confirmation**: NONE -- immediate mutation on click +- **Toast on success**: `variant: 'warning'`, title: "Deployment stopped" + +**INCONSISTENCY**: Stopping a deployment is a destructive operation that affects live services but has NO confirmation dialog. Route stop/suspend in RouteControlBar uses a ConfirmDialog, but deployment stop does not. + +### 1.12 Stop/Suspend Route +- **File**: `ui/src/pages/Exchanges/RouteControlBar.tsx` (lines 43-154) +- **Button location**: Route control bar (segmented button group) +- **Button**: Custom segmented `` +- **Confirmation**: NONE -- immediate call to `onDelete` then `onClose` +- **Toast**: Handled by parent component (ExchangesPage) + +**INCONSISTENCY**: Deleting a tap from the TapConfigModal has no confirmation, but deleting from the RouteDetail table shows a ConfirmDialog. + +### 1.15 Kill Database Query +- **File**: `ui/src/pages/Admin/DatabaseAdminPage.tsx` (line 30) +- **Button location**: Inline in active queries table +- **Button**: `` +- **Confirmation**: NONE -- immediate mutation +- **Toast**: None visible + +**INCONSISTENCY**: Killing a database query is a destructive action with no confirmation and no toast feedback. + +--- + +## 2. Button Placement & Order + +### 2.1 Create Forms (Users, Groups, Roles, Environments) + +All four entity create forms use an identical pattern: + +| Page | File | Line | Left Button | Right Button | +|------|------|------|-------------|--------------| +| Users | `UsersTab.tsx` | 254-274 | Cancel (ghost) | Create (primary) | +| Groups | `GroupsTab.tsx` | 251-268 | Cancel (ghost) | Create (primary) | +| Roles | `RolesTab.tsx` | 142-159 | Cancel (ghost) | Create (primary) | +| Environments | `EnvironmentsPage.tsx` | 181-194 | Cancel (ghost) | Create (primary) | + +- **Position**: Bottom of inline create form in the list pane +- **Container class**: `styles.createFormActions` +- **Order**: Cancel (left) | Create (right) -- **CONSISTENT** +- **Variants**: Cancel = `ghost`, Create = `primary` -- **CONSISTENT** +- **Size**: Both `sm` -- **CONSISTENT** + +### 2.2 App Creation Page + +- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 282-287) +- **Position**: Top of page in `detailActions` header area +- **Order**: Cancel (ghost, left) | Create & Deploy / Create (primary, right) +- **Size**: Both `sm` +- **CONSISTENT** with the pattern (Cancel left, Submit right) + +### 2.3 OIDC Config Page (Toolbar) + +- **File**: `ui/src/pages/Admin/OidcConfigPage.tsx` (lines 130-137) +- **Position**: Top toolbar +- **Order**: Test Connection (secondary, left) | Save (primary, right) +- **No Cancel button** -- form is always editable + +**NOTE**: This is the only admin page without a Cancel button or Edit mode toggle. + +### 2.4 App Detail Header + +- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 560-566) +- **Position**: Top-right header area in `detailActions` +- **Order**: Upload JAR (primary) | Delete App (danger) + +**NOTE**: The primary action (Upload) is on the LEFT and the destructive action (Delete) is on the RIGHT. + +### 2.5 App Config Detail Page (AppConfigDetailPage) + +- **File**: `ui/src/pages/Admin/AppConfigDetailPage.tsx` (lines 308-319) +- **Position**: Top toolbar +- **Read mode**: Back (ghost) ... Edit (secondary) +- **Edit mode**: Back (ghost) ... Save (default/no variant specified!) | Cancel (secondary) +- **Order when editing**: Save (left) | Cancel (right) + +**INCONSISTENCY #1**: Save button has NO `variant` prop set -- it renders as default, not `primary`. Every other Save button uses `variant="primary"`. + +**INCONSISTENCY #2**: Button order is REVERSED from every other form. Here it is Save (left) | Cancel (right). Everywhere else it is Cancel (left) | Save (right). + +### 2.6 App Config Sub-Tab (AppsTab ConfigSubTab) + +- **File**: `ui/src/pages/AppsTab/AppsTab.tsx` (lines 922-936) +- **Position**: Top banner bar (editBanner) +- **Read mode**: Banner text + Edit (secondary) +- **Edit mode**: Banner text + Cancel (ghost) | Save Configuration (primary) +- **Order when editing**: Cancel (left) | Save (right) -- **CONSISTENT** + +### 2.7 Environment Default Resources / JAR Retention Sections + +- **File**: `ui/src/pages/Admin/EnvironmentsPage.tsx` (lines 437-446, 505-514) +- **Position**: Bottom of section, right-aligned (`justifyContent: 'flex-end'`) +- **Read mode**: Edit Defaults / Edit Policy (secondary) +- **Edit mode**: Cancel (ghost) | Save (primary) -- **CONSISTENT** +- **Size**: Both `sm` + +### 2.8 User Password Reset + +- **File**: `ui/src/pages/Admin/UsersTab.tsx` (lines 407-431) +- **Position**: Inline in Security section +- **Order**: Cancel (ghost) | Set (primary) +- **CONSISTENT** pattern (Cancel left, Submit right) + +### 2.9 Tap Modal (TapConfigModal) + +- **File**: `ui/src/components/TapConfigModal.tsx` (lines 249-257) +- **Position**: Modal footer +- **Order (edit mode)**: Delete (danger, left, in `footerLeft`) | Cancel (secondary) | Save (primary) +- **Order (create mode)**: Cancel (secondary) | Save (primary) +- **No `size` prop specified** -- renders at default size + +**NOTE**: Uses `variant="secondary"` for Cancel, not `variant="ghost"` like create forms. + +### 2.10 Tap Modal (RouteDetail inline version) + +- **File**: `ui/src/pages/Routes/RouteDetail.tsx` (lines 984-986) +- **Position**: Modal footer (`tapModalFooter`) +- **Order**: Cancel (secondary) | Save (primary) +- **No `size` prop specified** +- **CONSISTENT** with TapConfigModal + +### 2.11 About Me Dialog + +- **File**: `ui/src/components/AboutMeDialog.tsx` (lines 14, 72) +- **Uses `Modal` with built-in close button** (no explicit action buttons) +- **Close via**: Modal `onClose` handler (X button and backdrop click) + +### 2.12 Login Page + +- **File**: `ui/src/auth/LoginPage.tsx` (lines 176-184) +- **Single button**: Sign in (primary, full width, submit type) +- **Optional SSO button above**: Sign in with SSO (secondary) + +### Summary of Button Order Patterns + +| Location | Cancel Side | Submit Side | Consistent? | +|----------|------------|-------------|-------------| +| User create form | Left (ghost) | Right (primary) | YES | +| Group create form | Left (ghost) | Right (primary) | YES | +| Role create form | Left (ghost) | Right (primary) | YES | +| Env create form | Left (ghost) | Right (primary) | YES | +| App create page | Left (ghost) | Right (primary) | YES | +| Env Default Resources edit | Left (ghost) | Right (primary) | YES | +| Env JAR Retention edit | Left (ghost) | Right (primary) | YES | +| AppsTab config sub-tab edit | Left (ghost) | Right (primary) | YES | +| User password reset | Left (ghost) | Right (primary) | YES | +| TapConfigModal | Left (secondary) | Right (primary) | Variant mismatch | +| RouteDetail tap modal | Left (secondary) | Right (primary) | Variant mismatch | +| **AppConfigDetailPage** | **Left (NO variant)** | **Right (secondary)** | **REVERSED** | + +--- + +## 3. Edit / Save Patterns + +### 3.1 Users (UsersTab) +- **Edit mode**: No explicit toggle. Display name uses `InlineEdit` (click-to-edit). Everything else is managed via tag add/remove. +- **No Save/Cancel for the detail view** -- all changes are immediate mutations. +- **Unsaved changes indicator**: N/A (no batched editing) +- **On success**: Toast with `variant: 'success'` +- **On error**: Toast with `variant: 'error'`, `duration: 86_400_000` (effectively permanent) + +### 3.2 Groups (GroupsTab) +- **Edit mode**: Name uses `InlineEdit`. All other changes (members, roles) are immediate mutations. +- **Pattern**: Same as Users -- no batched edit mode. + +### 3.3 Roles (RolesTab) +- **Edit mode**: Read-only detail panel. No editing of role fields. +- **Only action**: Delete + +### 3.4 Environments (EnvironmentsPage) +- **Edit mode (name)**: `InlineEdit` +- **Edit mode (production/enabled toggles)**: Immediate mutations per toggle change +- **Edit mode (Default Resources)**: Explicit Edit toggle (`setEditing(true)`) + - Cancel/Save buttons appear at bottom-right + - Resets form on cancel + - No unsaved changes indicator + - On success: Toast `variant: 'success'` +- **Edit mode (JAR Retention)**: Same pattern as Default Resources +- **On environment switch**: Both sub-sections auto-reset to read mode + +### 3.5 OIDC Config (OidcConfigPage) +- **Edit mode**: ALWAYS editable (no toggle) +- **Save button**: Always visible in top toolbar +- **No Cancel button** -- cannot discard changes +- **No unsaved changes indicator** +- **On success**: Toast `variant: 'success'` +- **On error**: Toast `variant: 'error'` + inline `` both shown + +**INCONSISTENCY**: Only page that is always editable with no way to discard changes. Also the only page that shows BOTH a toast AND an inline alert on error. + +### 3.6 App Config Detail (AppConfigDetailPage) +- **Edit mode**: Explicit toggle via `Edit` button (Pencil icon) in toolbar +- **Toolbar in edit mode**: Save (unstyled!) | Cancel (secondary) +- **Save button text**: Shows "Saving..." while pending +- **No unsaved changes indicator** +- **On success**: Toast `variant: 'success'`, exits edit mode +- **On error**: Toast `variant: 'error'`, stays in edit mode + +### 3.7 App Config Sub-Tab (AppsTab ConfigSubTab) +- **Edit mode**: Explicit toggle via banner + Edit button +- **Banner in read mode**: "Configuration is read-only. Enter edit mode to make changes." +- **Banner in edit mode**: "Editing configuration. Changes are not saved until you click Save." (styled differently with `editBannerActive`) +- **This IS an unsaved changes indicator** (the banner text changes) +- **Cancel/Save in edit banner**: Cancel (ghost) | Save Configuration (primary) +- **On success**: Toast `variant: 'success'`, exits edit mode, shows redeploy notice +- **On error**: Toast `variant: 'error'`, stays in edit mode + +### 3.8 App Create Page +- **Edit mode**: N/A (always a creation form) +- **Multi-step indicator**: Shows step text like "Creating app...", "Uploading JAR..." during submission +- **On success**: Toast `variant: 'success'`, navigates to app detail page +- **On error**: Toast `variant: 'error'` with step context + +### 3.9 Tap Editing (TapConfigModal + RouteDetail inline) +- **Edit mode**: Modal opens for edit or create +- **Save/Cancel**: In modal footer +- **On success**: Modal closes, parent handles toast +- **On error**: Parent handles toast + +### Summary of Edit Patterns + +| Page | Explicit Edit Toggle? | Unsaved Changes Indicator? | Consistent? | +|------|----------------------|---------------------------|-------------| +| Users | No (inline edits) | N/A | N/A | +| Groups | No (inline edits) | N/A | N/A | +| Roles | No (read-only) | N/A | N/A | +| Environments - name | No (InlineEdit) | N/A | OK | +| Environments - resources | YES | No | Missing | +| Environments - JAR retention | YES | No | Missing | +| OIDC Config | No (always editable) | No | Deviation | +| AppConfigDetailPage | YES | No | Missing | +| AppsTab ConfigSubTab | YES (banner) | YES (banner text) | Best pattern | + +**INCONSISTENCY**: The AppsTab ConfigSubTab is the only one with a proper unsaved-changes indicator. AppConfigDetailPage (which edits the same data for a different entry point) has no such indicator. + +--- + +## 4. Toast / Notification Patterns + +### 4.1 Toast Provider +- **File**: `ui/src/components/LayoutShell.tsx` (line 783) +- **Provider**: `` from `@cameleer/design-system` wraps the entire app layout +- **Hook**: `useToast()` returns `{ toast }` function + +### 4.2 Toast Call Signature +All toast calls use the same shape: +```typescript +toast({ + title: string, + description?: string, + variant: 'success' | 'error' | 'warning', + duration?: number +}) +``` + +### 4.3 Toast Variants Used + +| Variant | Used For | Duration | +|---------|----------|----------| +| `success` | Successful operations | Default (auto-dismiss) | +| `error` | Failed operations | `86_400_000` (24 hours = effectively permanent) | +| `warning` | Destructive successes (delete, stop) AND partial failures | Mixed (see below) | + +### 4.4 Duration Patterns + +- **Success toasts**: No explicit duration (uses design system default) -- **CONSISTENT** +- **Error toasts**: Always `duration: 86_400_000` -- **CONSISTENT** (49 occurrences across 10 files) +- **Warning toasts for deletion success** (user/group/role/env/OIDC/app deleted): No explicit duration (auto-dismiss) -- **CONSISTENT** +- **Warning toasts for partial push failures**: `duration: 86_400_000` -- **CONSISTENT** + +### 4.5 Naming Conventions for Toast Titles + +**Success pattern**: Action-noun format +- "User created", "Group created", "Role created", "Environment created" +- "Display name updated", "Password updated", "Group renamed" +- "Config saved", "Configuration saved", "Tap configuration saved" + +**Error pattern**: "Failed to [action]" format +- "Failed to create user", "Failed to delete group", "Failed to update password" +- "Save failed", "Upload failed", "Deploy failed" (shorter form) + +**INCONSISTENCY**: Error messages mix two patterns: +1. "Failed to [verb] [noun]" (e.g., "Failed to create user") -- used in RBAC pages +2. "[Noun] failed" (e.g., "Save failed", "Upload failed") -- used in AppsTab, AppConfigDetailPage + +### 4.6 Warning Variant for Deletions + +Successful deletions use `variant: 'warning'` consistently: +- "User deleted" (UsersTab:162) +- "Group deleted" (GroupsTab:147) +- "Role deleted" (RolesTab:100) +- "Environment deleted" (EnvironmentsPage:105) +- "Configuration deleted" (OidcConfigPage:119) +- "App deleted" (AppsTab:536) +- "Deployment stopped" (AppsTab:529) + +**CONSISTENT** -- all destructive-but-successful operations use warning. + +--- + +## 5. Loading / Empty States + +### 5.1 Full-Page Loading States + +| Page | Component | Size | Wrapper | +|------|-----------|------|---------| +| UsersTab | `` | md | Bare return | +| GroupsTab | `` | md | Bare return | +| RolesTab | `` | md | Bare return | +| EnvironmentsPage | `` | md | Bare return | +| AppListView | `` | md | Bare return | +| AppDetailView | `` | md | Bare return | +| AgentInstance | `` | **lg** | Bare return | +| AppConfigDetailPage | `` | **lg** | Wrapped in `div.loading` | +| DashboardPage | `` | lg | Centered container | +| RuntimePage | `` | lg | Centered container | +| OidcConfigPage | `return null` | N/A | Returns nothing | + +**INCONSISTENCY #1**: Most admin pages use `` as a bare return. AgentInstance and AppConfigDetailPage use `size="lg"`. DashboardPage and RuntimePage use the `` component which wraps `` in a centered container. + +**INCONSISTENCY #2**: OidcConfigPage returns `null` while loading (shows a blank page), unlike every other page. + +**INCONSISTENCY #3**: SplitPane detail loading (GroupsTab line 317, RolesTab line 212) uses `` -- consistent within that context. + +### 5.2 Section Loading States + +- **RouteDetail charts**: `` inline in chart containers (lines 713, 804) +- **AboutMeDialog**: `` in a `div.loading` wrapper + +### 5.3 Empty States + +| Context | Pattern | Component Used | +|---------|---------|----------------| +| SplitPane list (no search match) | `emptyMessage="No X match your search"` | EntityList built-in | +| SplitPane detail (nothing selected) | `emptyMessage="Select a X to view details"` | SplitPane built-in | +| Deployments table (none) | `

No deployments yet.

` | Plain `

` | +| Versions list (none) | `

No versions uploaded yet.

` | Plain `

` | +| Env vars (none, not editing) | `

No environment variables configured.

` | Plain `

` | +| Traces/Taps (none) | `

No processor traces or taps configured.

` | Plain `

` | +| Route recording (none) | `

No routes found for this application.

` | Plain `

` | +| AgentInstance metrics | `` | EmptyState (DS component) | +| Log/Event panels | `

No events...
` | Styled `
` | +| OIDC default roles | `No default roles configured` | `` | +| Group members (none) | `(no members)` | `` | +| AppConfigDetailPage (not found) | `
No configuration found for "{appId}".
` | Plain `
` | +| RouteDetail error patterns | `
No error patterns found...
` | Styled `
` | +| RouteDetail taps (none) | `
No taps configured...
` | Styled `
` | + +**INCONSISTENCY**: Empty states use at least 5 different approaches: +1. Design system `EmptyState` component (only in AgentInstance) +2. `

` (AppsTab) +3. `` with parenthetical format "(none)" (RBAC pages) +4. `

` (RouteDetail) +5. Unstyled inline text (AppConfigDetailPage) + +The design system provides an `EmptyState` component but it is only used in one place (AgentInstance). + +--- + +## 6. Inconsistency Summary + +### HIGH Priority (User-facing confusion) + +1. **AppConfigDetailPage button order is reversed** (Save|Cancel instead of Cancel|Save) and Save button has no `variant="primary"`. File: `ui/src/pages/Admin/AppConfigDetailPage.tsx`, lines 311-315. + +2. **Deployment Stop has no confirmation dialog**. Stopping a running deployment immediately executes with no confirmation, while stopping/suspending a route shows a ConfirmDialog. File: `ui/src/pages/AppsTab/AppsTab.tsx`, line 672. + +3. **Tap deletion is inconsistent**. Deleting from TapConfigModal: no confirmation. Deleting from RouteDetail table: ConfirmDialog. File: `ui/src/components/TapConfigModal.tsx` line 117 vs `ui/src/pages/Routes/RouteDetail.tsx` line 992. + +4. **Kill Query has no confirmation and no feedback**. File: `ui/src/pages/Admin/DatabaseAdminPage.tsx`, line 30. + +### MEDIUM Priority (Pattern deviations) + +5. **Cancel button variant inconsistency**. Create forms use `variant="ghost"` for Cancel. Modal dialogs (TapConfigModal, RouteDetail tap modal) use `variant="secondary"`. File: `ui/src/components/TapConfigModal.tsx` line 255, vs `ui/src/pages/Admin/UsersTab.tsx` line 258. + +6. **Removing a role from a user has no confirmation** but removing a group from a user shows an AlertDialog. Both can cascade. File: `ui/src/pages/Admin/UsersTab.tsx`, lines 504-528 vs 588-613. + +7. **OIDC Config is always editable with no Cancel/discard**. Every other editable form either has inline-edit (immediate save) or explicit edit mode with Cancel. File: `ui/src/pages/Admin/OidcConfigPage.tsx`. + +8. **OIDC Config delete ConfirmDialog missing `loading` prop**. All other delete ConfirmDialogs pass `loading={mutation.isPending}`. File: `ui/src/pages/Admin/OidcConfigPage.tsx`, line 258. + +9. **Loading state size inconsistency**. Most pages use `Spinner size="md"`, some use `size="lg"`, some use `PageLoader`, and OidcConfigPage returns `null`. No single standard. + +10. **Error toast title format inconsistency**. RBAC pages use "Failed to [verb] [noun]" while AppsTab/AppConfigDetailPage use "[Noun] failed". Should pick one. + +### LOW Priority (Minor deviations) + +11. **Empty state presentation varies widely**. Five different approaches used. Should standardize on the design system `EmptyState` component or at least a consistent CSS class. + +12. **ConfirmDialog confirmText varies between display name and slug**. Users/Groups/Roles use display name; Environments and Apps use slug. This is arguably intentional (slug is the technical identifier) but may confuse users. + +13. **OIDC Config shows both toast and inline Alert on error**. No other page shows both simultaneously. File: `ui/src/pages/Admin/OidcConfigPage.tsx`, line 92 (toast) + line 139 (inline Alert). + +14. **AppConfigDetailPage Save button text changes to "Saving..."** using string interpolation, while every other page uses the `loading` prop on Button (which shows a spinner). File: `ui/src/pages/Admin/AppConfigDetailPage.tsx`, line 313. + +15. **Unsaved changes indicator** only present on AppsTab ConfigSubTab (banner text). AppConfigDetailPage, Environment resource sections, and JAR retention section have no indicator even though they use explicit edit mode. + +--- + +## 7. ConfirmDialog Usage Matrix + +| Object | File | Line | confirmText Source | Has `loading`? | Has `variant`? | Has `confirmLabel`? | +|--------|------|------|-------------------|----------------|----------------|---------------------| +| User | UsersTab.tsx | 580 | displayName | YES | No (default) | No (default) | +| Group | GroupsTab.tsx | 434 | name | YES | No (default) | No (default) | +| Role | RolesTab.tsx | 223 | name | YES | No (default) | No (default) | +| Environment | EnvironmentsPage.tsx | 319 | slug | YES | No (default) | No (default) | +| OIDC Config | OidcConfigPage.tsx | 258 | "delete oidc" | **NO** | No (default) | No (default) | +| App | AppsTab.tsx | 589 | slug | YES | No (default) | No (default) | +| Tap (RouteDetail) | RouteDetail.tsx | 992 | attributeName | **NO** | `danger` | `"Delete"` | +| Route Stop | RouteControlBar.tsx | 139 | action name | YES | `danger`/`warning` | `"Stop Route"` / `"Suspend Route"` | + +**NOTE**: RouteControlBar and RouteDetail set explicit `variant` and `confirmLabel` on ConfirmDialog while all RBAC/admin pages use defaults. This creates visual differences in the confirmation dialogs. + +--- + +## 8. AlertDialog Usage Matrix + +| Context | File | Line | Title | Confirm Label | Variant | +|---------|------|------|-------|---------------|---------| +| Remove group from user | UsersTab.tsx | 588 | "Remove group membership" | "Remove" | `warning` | +| Remove role from group | GroupsTab.tsx | 442 | "Remove role from group" | "Remove" | `warning` | + +AlertDialog is used consistently where present (both use `warning` variant and "Remove" label). + +--- + +## 9. Files Examined + +All `.tsx` files under `ui/src/pages/` and `ui/src/components/`: + +- `ui/src/pages/Admin/UsersTab.tsx` +- `ui/src/pages/Admin/GroupsTab.tsx` +- `ui/src/pages/Admin/RolesTab.tsx` +- `ui/src/pages/Admin/EnvironmentsPage.tsx` +- `ui/src/pages/Admin/OidcConfigPage.tsx` +- `ui/src/pages/Admin/AppConfigDetailPage.tsx` +- `ui/src/pages/Admin/DatabaseAdminPage.tsx` +- `ui/src/pages/Admin/ClickHouseAdminPage.tsx` +- `ui/src/pages/Admin/AuditLogPage.tsx` +- `ui/src/pages/AppsTab/AppsTab.tsx` +- `ui/src/pages/Routes/RouteDetail.tsx` +- `ui/src/pages/Exchanges/ExchangesPage.tsx` +- `ui/src/pages/Exchanges/RouteControlBar.tsx` +- `ui/src/pages/AgentHealth/AgentHealth.tsx` +- `ui/src/pages/AgentInstance/AgentInstance.tsx` +- `ui/src/pages/DashboardTab/DashboardPage.tsx` +- `ui/src/pages/RuntimeTab/RuntimePage.tsx` +- `ui/src/components/TapConfigModal.tsx` +- `ui/src/components/AboutMeDialog.tsx` +- `ui/src/components/PageLoader.tsx` +- `ui/src/components/LayoutShell.tsx` +- `ui/src/auth/LoginPage.tsx` diff --git a/audit/monitoring-pages-findings.md b/audit/monitoring-pages-findings.md new file mode 100644 index 00000000..683f4f3a --- /dev/null +++ b/audit/monitoring-pages-findings.md @@ -0,0 +1,267 @@ +# Cameleer3 Web UI - UX Audit Findings + +**Date:** 2026-04-09 +**URL:** https://desktop-fb5vgj9.siegeln.internal/server/ +**Build:** 69dcce2 +**Auditor:** Claude (automated browser audit) + +--- + +## 1. Exchange Detail (Split View) + +**Screenshots:** `04-exchange-detail-ok.png`, `05-exchange-detail-err.png`, `27-exchange-err-error-tab.png` + +### What Works Well +- Split view layout (50/50) is clean and efficient -- table on left, detail on right +- Processor timeline visualization is excellent -- clear step sequence with color-coded status (green OK, red/amber error) +- Exchange detail tabs (Info, Headers, Input, Output, Error, Config, Timeline, Log) are comprehensive +- Error tab shows full Java stack trace with Copy button and exception message prominently displayed +- ERR rows in table have clear red status badge with icon +- Correlated exchanges section present (even when none found) +- JSON download button available on the detail view + +### Issues Found + +**Important:** +- **Exchange ID is raw hex, hard to scan.** The IDs like `96E395B0088AA6D-000000000001ED46` are 33+ characters wide. They push the table columns apart and are hard for humans to parse. Consider truncating with copy-on-click or showing a short hash. +- **Attributes column always shows "--".** Every single exchange row displays "--" in the Attributes column. If no attributes are captured, this column wastes horizontal space. Consider hiding it when empty or showing it only when relevant data exists. +- **Status shows "OK" but detail shows "COMPLETED".** The table status column shows "OK" / "ERR" but the detail panel shows "COMPLETED" / "FAILED". This terminology mismatch is confusing -- pick one convention. + +**Nice-to-have:** +- **No breadcrumb update when exchange selected.** The breadcrumb still shows "All Applications" even when viewing a specific exchange detail. Should show: All Applications > sample-app > Exchange 96E39... +- **No action buttons on exchange detail.** No "Replay", "Trace", or "View Route" buttons in the detail view. Users would benefit from contextual actions. +- **Back navigation relies on de-selecting the row.** There is no explicit "Close" or "Back" button on the detail panel. + +--- + +## 2. Dashboard Tab + +**Screenshots:** `07-dashboard-full.png`, `08-dashboard-drilldown.png` + +### What Works Well +- KPI strip is clean and scannable: Throughput (7/s), Success Rate (98.0%), P99 Latency (6695ms), SLA Compliance (38.0%), Active Errors (3) +- L1 (applications) -> L2 (routes) drill-down works via table row click +- L2 view shows comprehensive route performance table with throughput, success %, avg/P99, SLA %, sparkline +- Top Errors table with error velocity and "last seen" is very useful +- Charts: Throughput by Application, Error Rate, Volume vs SLA Compliance, 7-Day Pattern heatmap +- Color coding is consistent (amber for primary metrics, red for errors) +- Auto-refresh indicator shows "Auto-refresh: 30s" + +### Issues Found + +**Important:** +- **Application Health table row click is blocked by overlapping elements.** Playwright detected `_tableSection` and `_chartGrid` divs intercepting pointer events on the table row. While JavaScript `.click()` works, this means CSS `pointer-events` or `z-index` is wrong -- real mouse clicks may be unreliable depending on scroll position. +- **SLA Compliance 0.0% shows "BREACH" label** in L2 view but no explanation of what the SLA threshold is until you look closely at the latency chart. The SLA threshold (300ms) should be shown next to the KPI, not just in the chart. +- **7-Day Pattern heatmap is flat/empty.** The heatmap shows data only for the current day, making it look broken for a fresh deployment. Consider showing "Insufficient data" when less than 2 days of data exist. +- **"Application Volume vs SLA Compliance" bubble chart** truncates long application names (e.g., "complex-fulfil..." in L2). The chart has limited space for labels. + +**Nice-to-have:** +- **No trend arrows on KPI values in L2.** The L1 dashboard shows up/down arrows (all "up"), but L2 KPIs show percentage change text instead. The two levels should be consistent. +- **P99 latency 6695ms is not formatted as seconds.** Values over 1000ms should display as "6.7s" for readability. The L2 view uses raw milliseconds (1345ms) which is also inconsistent with the L1 (6695ms) and the exchange list which does format durations. +- **Throughput numbers use locale-specific formatting.** In the route table: `1.050` (German locale?) vs `14.377` -- these look like decimal numbers rather than thousands. Consider using explicit thousands separator or always using K suffix. + +--- + +## 3. Runtime Tab + +**Screenshots:** `09-runtime-tab.png`, `09-runtime-full.png`, `10-runtime-agent-detail.png`, `24-runtime-agent-detail-full.png` + +### What Works Well +- KPI strip: Total Agents (3), Applications (1), Active Routes (30/0), Total TPS (4.8), Dead (0) -- clear at a glance +- Agent state indicators are clear: green "LIVE" badges, "3/3 LIVE" summary +- Instance table shows key metrics: State, Uptime, TPS, Errors, Heartbeat +- Clicking an agent row navigates to a rich detail view with 6 charts (CPU, Memory, Throughput, Error Rate, Thread Count, GC Pauses) +- Agent capabilities displayed as badges (LOGFORWARDING, DIAGRAMS, TRACING, METRICS) +- Application Log viewer with level filtering (Error/Warn/Info/Debug/Trace) and auto-scroll +- Timeline shows agent events (CONFIG_APPLIED, COMMAND_SUCCESS) with relative timestamps + +### Issues Found + +**Critical:** +- **GC Pauses chart X-axis is unreadable.** The chart renders ~60 full ISO-8601 timestamps (`2026-04-09T14:16:00Z` through `2026-04-09T15:15:00Z`) as X-axis labels. These overlap completely and form an unreadable block of text. All other charts use concise numeric labels (e.g., "12", "24"). The GC Pauses chart should use the same time formatting. + +**Important:** +- **Agent state shows "UNKNOWN" alongside "LIVE".** The detail view shows both "LIVE" and "UNKNOWN" state indicators. The "UNKNOWN" appears to be a secondary state field (perhaps container state?) but it is confusing to show two conflicting states without explanation. +- **Memory chart shows absolute MB values but no percentage on Y-axis.** The KPI shows "46% / 57 MB / 124 MB" which is great, but the chart Y-axis goes from 0-68 MB which doesn't match the 124 MB limit. The max heap should be indicated on the chart (e.g., as a reference line). +- **Throughput chart Y-axis scale is wildly mismatched.** The KPI shows 2.0 msg/s but the Y-axis goes to 1.2k msg/s, making the actual data appear as a flat line near zero. The Y-axis should auto-scale to the actual data range. +- **Error Rate chart Y-axis shows "err/h"** but the unit inconsistency with the KPI (which shows percentage "1.7%") is confusing. + +**Nice-to-have:** +- **"DEAD 0" KPI in the overview is redundant** when "all healthy" text is already shown below it. Consider combining or removing the redundant label. +- **Application Log shows "0 entries"** in the overview but "100 entries" in the agent detail. The overview log may not aggregate across agents, which is misleading. + +--- + +## 4. Deployments Tab + +**Screenshots:** `12-deployments-list.png`, `25-app-detail.png`, `11-deployments-tab.png` + +### What Works Well +- App list is clean: Name, Environment (with colored badges DEFAULT/DEVELOPMENT), Updated, Created columns +- App detail page shows configuration tabs: Monitoring, Resources, Variables, Traces & Taps, Route Recording +- Read-only mode with explicit "Edit" button prevents accidental changes +- "Upload JAR" and "Delete App" action buttons are visible +- Create Application form (`/apps/new`) is comprehensive with Identity & Artifact section, deploy toggle, and monitoring sub-tabs + +### Issues Found + +**Important:** +- **Navigating to `/server/apps` redirected to `/server/apps/new`** on the initial visit, bypassing the apps list. This happened once but not consistently. The default route for the Deployments tab should always be the list view, not the create form. +- **No deployment status/progress visible in the list.** The apps list shows "RUNNING" status only in the detail view. The list should show the deployment status directly (RUNNING/STOPPED/FAILED badge per row). +- **"Updated: 59m ago" is relative time** which becomes stale if the page is left open. Consider showing absolute timestamp on hover. + +**Nice-to-have:** +- **Configuration form select dropdowns** (Engine Level, Payload Capture, App Log Level, etc.) all use native HTML selects with a custom `"triangle"` indicator -- this is inconsistent with the design system's `Select` component used elsewhere. +- **"External URL" field shows `/default/.../`** placeholder which is cryptic. Should show the full resolved URL or explain the pattern. + +--- + +## 5. Command Palette (Ctrl+K) + +**Screenshots:** `14-command-palette.png`, `15-command-palette-search.png`, `16-command-palette-keyboard.png` + +### What Works Well +- Opens instantly with Ctrl+K +- Shows categorized results: All (24), Applications (1), Exchanges (10), Routes (10), Agents (3) +- Search is fast and filters results in real-time (typed "error" -> filtered to 11 results) +- Search term highlighting (yellow background on matched text) +- Keyboard navigation works (ArrowDown moves selection) +- Rich result items: exchange IDs with status, routes with app name and exchange count, applications with agent count +- Escape closes the palette +- Category tabs allow filtering by type + +### Issues Found + +**Nice-to-have:** +- **Exchange IDs in search results are full hex strings.** The same issue as the exchanges table -- `5EF55FC31352A9A-000000000001F07C` is hard to scan. Show a shorter preview. +- **No keyboard shortcut hints in results.** Results don't show "Enter to open" or "Tab to switch category" -- users must discover these by trial. +- **Category counts don't update when filtering.** When I typed "error", the category tabs still show the original counts (Applications, Exchanges 10, Routes 1, Agents) but some categories become empty. The empty categories should hide or dim. + +--- + +## 6. Dark Mode + +**Screenshots:** `17-dark-mode-exchanges.png`, `18-dark-mode-dashboard.png`, `19-dark-mode-runtime.png` + +### What Works Well +- Dark mode applies cleanly across all pages +- Table rows have good contrast (light text on dark background) +- Status badges (OK green, ERR red) remain clearly visible +- Chart lines and data points are visible against dark backgrounds +- KPI cards have distinct dark card backgrounds with readable text +- The dark mode toggle is easy to find (moon icon in header) +- Theme preference persists in localStorage (`cameleer-theme`) + +### Issues Found + +**Important:** +- **Chart backgrounds appear as opaque dark cards but chart lines may be harder to see.** The throughput and error rate charts use amber/orange lines on dark gray backgrounds -- this is acceptable but not ideal. Consider slightly brighter chart colors in dark mode. +- **Application Volume vs SLA chart** in dashboard: the bubble/bar labels may have low contrast in dark mode (hard to verify at screenshot resolution). + +**Nice-to-have:** +- **Sidebar border/separator** between the sidebar and main content area is very subtle in dark mode. A slightly more visible divider would help. +- **Environment badges** (DEFAULT in gold, DEVELOPMENT in orange) are designed for light mode and may look less distinct against the dark background. + +--- + +## 7. Cross-Cutting Interaction Issues + +### Status Filter Buttons (OK/Warn/Error/Running) + +**Screenshots:** `03-exchanges-error-filtered.png` + +**Important:** +- **Error filter works correctly** -- clicking the Error button filters to show only ERR exchanges (447 in the test). The button shows active/pressed state. +- **Filter state is not preserved in URL.** Navigating away and back loses the filter. Consider encoding active filters in the URL query string. +- **KPI strip does not update when filter is active.** When Error filter is active, the KPI strip still shows overall stats (Total 23.4K, Err% 1.9%). It should either update to show filtered stats or clearly indicate it shows overall stats. + +### Column Sorting + +**Screenshot:** `23-sorting-route.png` + +- Sorting works correctly (Route column sorted alphabetically, "audit-log" rows grouped) +- Sort indicator arrow is visible on the column header +- **Sorting is client-side only (within the 50-row page).** With 23K+ exchanges, sorting only the visible page is misleading. Consider either fetching sorted data from the server or clearly labeling "sorted within current page." + +### Pagination + +- Pagination works: "1-25 of 50", page 1/2, rows per page selector (10/25/50/100) +- Next/Previous page buttons work +- **"50 of 23,485 exchanges" label is confusing.** The "50" refers to the server-side limit (max fetched), not the page size (25). This should read "Showing 1-25 of 23,485" or similar. + +### Sidebar App Tree + +**Screenshot:** `20-sidebar-expanded.png` + +- Expand/collapse works for "sample app" +- Shows all 10 routes with exchange counts (audit-log 5.3k, file-processing 114.2k, etc.) +- Exchange counts use K-suffix formatting which is good +- **Add to starred button is present** (star icon on the app) + +### Environment Selector + +- Dropdown works: All Envs / default / development +- Switching environment correctly filters data (65K -> 3.5K exchanges) +- Selection persists in localStorage + +### Time Range Pills + +**Screenshot:** `21-time-range-3h.png` + +- Time range pills work (1h, 3h, 6h, Today, 24h, 7d) +- Switching updates data and KPI strip correctly +- Custom date range is shown: "9. Apr. 16:14 -- now" with clickable start/end timestamps +- **Date formatting uses European style** ("9. Apr. 16:14") which is fine but inconsistent with ISO timestamps elsewhere. + +--- + +## 8. Systematic Navigation Bug + +**Critical:** + +During the audit, the browser consistently auto-redirected from any page to `/server/admin/rbac` (Users & Roles) after interactions involving the Playwright accessibility snapshot tool. This happened: +- After taking snapshots of the exchanges page +- After clicking exchange detail rows +- After interacting with filter buttons +- After attempting to click table rows + +The redirect does **not** happen when using only JavaScript-based interactions (`page.evaluate`) without the Playwright snapshot/click methods. The root cause appears to be that the Playwright MCP accessibility snapshot tool triggers focus/click events on sidebar items (specifically "Users & Roles"), causing unintended navigation. + +**While this is likely a tool interaction artifact rather than a real user-facing bug**, it reveals that: +1. The sidebar tree items may have overly aggressive focus/activation behavior (activating on focus rather than explicit click) +2. There may be no route guard preventing unexpected navigation when the user hasn't explicitly clicked a sidebar item + +Recommend investigating whether keyboard focus on sidebar tree items triggers navigation (it should require Enter/click, not just focus). + +--- + +## Summary of Issues by Severity + +### Critical (1) +1. **GC Pauses chart X-axis renders ~60 full ISO timestamps** -- completely unreadable (Runtime > Agent Detail) + +### Important (10) +1. **Exchange ID columns are too wide** -- 33-char hex strings push table layout (Exchanges) +2. **Attributes column always shows "--"** -- wastes space (Exchanges) +3. **Status terminology mismatch** -- "OK/ERR" in table vs "COMPLETED/FAILED" in detail (Exchange Detail) +4. **Dashboard table row clicks intercepted by overlapping divs** -- z-index/pointer-events issue (Dashboard) +5. **SLA threshold not shown on KPI** -- have to find it in the chart (Dashboard L2) +6. **Agent state shows "UNKNOWN" alongside "LIVE"** -- confusing dual state (Runtime Agent Detail) +7. **Throughput chart Y-axis scale mismatch** -- 2 msg/s data on 1.2k scale, appears flat (Runtime Agent Detail) +8. **Error Rate chart unit mismatch** -- "err/h" on chart vs "%" on KPI (Runtime Agent Detail) +9. **Filter state not preserved in URL** (Exchanges) +10. **"50 of 23,485 exchanges" pagination label is confusing** (Exchanges) + +### Nice-to-have (12) +1. No breadcrumb update when exchange selected +2. No action buttons (Replay/Trace) on exchange detail +3. No explicit Close/Back button on detail panel +4. P99 latency not formatted as seconds when >1000ms +5. Throughput numbers use locale-specific decimal formatting +6. 7-Day Pattern heatmap appears empty with limited data +7. Exchange IDs in command palette are full hex strings +8. No keyboard shortcut hints in command palette results +9. Sidebar border subtle in dark mode +10. Deployment list doesn't show status badges +11. "Updated: 59m ago" relative time goes stale +12. Category counts in command palette don't update when filtering diff --git a/docs/superpowers/specs/2026-04-09-ux-polish-design.md b/docs/superpowers/specs/2026-04-09-ux-polish-design.md new file mode 100644 index 00000000..e8e682e2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-ux-polish-design.md @@ -0,0 +1,531 @@ +# UX Polish & Bug Fixes — Design Spec + +**Date:** 2026-04-09 +**Scope:** Bug fixes, design consistency, interaction consistency, contrast/readability, data formatting, chart fixes, admin polish +**Out of scope:** Feature work (onboarding, alerting, shareable links, latency investigation) — tracked in Epic #100 + +## Context + +Comprehensive Playwright-driven audit of the live Cameleer3 UI (build 69dcce2, 60+ screenshots) combined with the existing UI_FINDINGS.md visual audit (2026-03-25) and 16 open Gitea issues. Three code-level audits performed: layout consistency (CSS modules), interaction patterns (dialogs, buttons, toasts), and design system adoption. + +Audit artifacts in `audit/`: +- `monitoring-pages-findings.md` — exchanges, dashboard, runtime, deployments, command palette, dark mode +- `admin-lifecycle-findings.md` — RBAC CRUD, audit log, OIDC, environments, database, ClickHouse, platform +- `design-consistency-findings.md` — per-page CSS module usage, container padding, card patterns +- `interaction-patterns-findings.md` — confirmation dialogs, button order, edit/save flows, toasts, loading/empty states +- 60+ screenshots + +## Implementation Strategy + +8 theme-based batches, ordered by impact. Each batch groups related fixes that share code paths. + +--- + +## Batch 1: Critical Bug Fixes + +**Effort:** 1 day + +### 1.1 SSE Navigation Bug + +**Problem:** Admin pages sporadically redirect to `/server/exchanges` during form editing. The SSE exchange data stream triggers React state updates that cause route changes, losing unsaved work. + +**Fix:** Guard SSE-driven state updates so they never trigger navigation when the current route is outside the exchanges scope. The exchange list polling/SSE should update data stores without pushing route state. Likely in `LayoutShell.tsx` or the SSE connection manager — the exchange data subscription must be decoupled from route navigation. + +### 1.2 User Creation in OIDC Mode + +**Problem:** `UserAdminController.createUser()` returns `ResponseEntity.badRequest().build()` (empty body) when OIDC is enabled. The UI still shows "+ Add user" and the full creation form. Toast says "Failed to create user" with no explanation. + +**Fix (backend):** Return `{ "error": "Local user creation is disabled when OIDC is enabled" }` as the response body. + +**Fix (frontend):** When OIDC is enabled, show an inline banner replacing the create form: "Local user creation is disabled when OIDC is active. Users are provisioned automatically via SSO." Hide or disable the "+ Add user" button. + +**Files:** `UserAdminController.java:92-93`, `UsersTab.tsx` (create form section) + +### 1.3 `/server/deployments` 404 + +**Problem:** Direct URL shows unhandled React Router dev error. The Deployments tab lives at `/server/apps`. + +**Fix:** Add redirect route in `router.tsx`: `{ path: "deployments", element: }` + +**Files:** `ui/src/router.tsx` + +### 1.4 GC Pauses Chart X-axis + +**Problem:** Renders ~60 full ISO-8601 timestamps overlapping into an unreadable block. All other agent charts (CPU, Memory, Throughput, Error Rate, Thread Count) use concise time labels. + +**Fix:** Use the same X-axis time formatter as the other 5 agent charts. The GC Pauses chart likely passes raw ISO strings to the chart library instead of using the shared time axis configuration. + +**Files:** `AgentInstance.tsx` or `AgentInstance.module.css` (GC Pauses chart config) + +--- + +## Batch 2a: Design/Layout Consistency + +**Effort:** 2 days + +Reference: `audit/design-consistency-findings.md` + +### 2a.1 Exchanges Table Containment + +**Problem:** Only full-bleed table in the app. `Dashboard.tsx` rolls its own `.tableHeader` with `padding: 8px 12px` instead of using `table-section.module.css` (shared: `12px 16px`). No card wrapper. + +**Fix:** Import and use `tableStyles.tableSection` wrapper. Replace custom `.tableHeader`, `.tableTitle`, `.tableRight`, `.tableMeta` with the shared module classes. The exchange table should look like every other table (Audit Log, ClickHouse, Dashboard L2/L3). + +**Files:** `pages/Dashboard/Dashboard.tsx`, `pages/Dashboard/Dashboard.module.css` + +### 2a.2 App Detail/Create App Flat Controls + +**Problem:** `AppsTab.tsx` renders all form controls flat against the background. Config tabs (Monitoring, Resources, Variables, Traces & Taps, Route Recording) have labels and controls but no card wrappers. Controls "mesh into background." + +**Fix:** Wrap each configuration group in `sectionStyles.section` from `section-card.module.css`, matching the OIDC page pattern (`OidcConfigPage.tsx`) and AppConfigDetail pattern (`AppConfigDetailPage.tsx`). Each sub-tab's content gets a section card. + +**Files:** `pages/AppsTab/AppsTab.tsx`, `pages/AppsTab/AppsTab.module.css` + +### 2a.3 Apps Deployment Table + +**Problem:** Uses raw HTML `
` instead of `DataTable` with no `tableStyles.tableSection` wrapper. + +**Fix:** Replace manual `
` with `DataTable` inside `tableStyles.tableSection`, matching the Audit Log pattern. + +**Files:** `pages/AppsTab/AppsTab.tsx` + +### 2a.4 Container Padding Normalization + +**Problem:** Three different strategies: Exchanges/Dashboard = no padding, Runtime/Admin = `20px 24px 40px`, Apps = `16px`. + +**Fix:** +- Standardize on `20px 24px 40px` for all scrollable content pages +- Apps: change from `16px` to `20px 24px 40px` +- Dashboard: add `padding: 0 24px 20px` for side margins (keep gap-based vertical spacing) +- Exchanges: exception — split-view height-filling layout needs no padding + +**Files:** `pages/AppsTab/AppsTab.module.css` (`.container`), `pages/DashboardTab/DashboardTab.module.css` (`.content`) + +### 2a.5 Deduplicate Card CSS + +**Problem:** The `bg-surface + border + border-radius + box-shadow` card pattern is copy-pasted in 8+ locations instead of importing shared modules. + +**Fix:** Replace custom card declarations with imports from `section-card.module.css` or `table-section.module.css`: +- `DashboardTab.module.css` — `.errorsSection`, `.diagramSection` -> `tableStyles.tableSection` +- `AgentHealth.module.css` — `.configBar`, `.eventCard` -> `sectionStyles.section` +- `AgentInstance.module.css` — `.processCard`, `.timelineCard` -> `sectionStyles.section` +- `ClickHouseAdminPage.module.css` — `.pipelineCard` -> `sectionStyles.section` + +### 2a.6 Admin Detail Pages Flat Forms + +**Problem:** RBAC detail (Users/Groups) and Environments detail render form controls without section cards. + +**Fix:** Wrap detail sections in `sectionStyles.section`. For master-detail pages, the detail panel should use section cards to group related fields (Identity, Roles, Group Membership, etc.). + +**Files:** `pages/Admin/UsersTab.tsx`, `pages/Admin/GroupsTab.tsx`, `pages/Admin/EnvironmentsPage.tsx` + +### 2a.7 Database Admin Table + +**Problem:** Uses `DataTable` without `tableStyles.tableSection` wrapper. + +**Fix:** Wrap in `tableStyles.tableSection` like Audit Log and ClickHouse. + +**Files:** `pages/Admin/DatabaseAdminPage.tsx` + +--- + +## Batch 2b: Interaction Pattern Consistency + +**Effort:** 2 days + +Reference: `audit/interaction-patterns-findings.md` + +### 2b.1 AppConfigDetailPage Button Order Reversed (HIGH) + +**Problem:** Save|Cancel order (lines 311-315), reversed from every other form. Save button has no `variant="primary"`. + +**Fix:** Swap to Cancel (ghost, left) | Save (primary, right). + +**Files:** `pages/Admin/AppConfigDetailPage.tsx:311-315` + +### 2b.2 Deployment Stop Needs Confirmation (HIGH) + +**Problem:** `AppsTab.tsx:672` — stopping a running deployment is immediate. Route Stop/Suspend uses ConfirmDialog. + +**Fix:** Add `ConfirmDialog`: "Stop deployment for {appName}? This will take the service offline." Type-to-confirm with app slug. + +**Files:** `pages/AppsTab/AppsTab.tsx:672` + +### 2b.3 Tap Deletion Inconsistency (HIGH) + +**Problem:** `TapConfigModal.tsx:117` — delete is immediate. `RouteDetail.tsx:992` — shows ConfirmDialog. + +**Fix:** Add ConfirmDialog to TapConfigModal delete, matching RouteDetail. + +**Files:** `components/TapConfigModal.tsx:117` + +### 2b.4 Kill Query Unguarded (HIGH) + +**Problem:** `DatabaseAdminPage.tsx:30` — no confirmation, no toast. + +**Fix:** Add AlertDialog ("Kill query {pid}?") and success/error toast. + +**Files:** `pages/Admin/DatabaseAdminPage.tsx:30` + +### 2b.5 Role Removal From User Unguarded (MEDIUM) + +**Problem:** `UsersTab.tsx:504-528` — role removal is immediate. Group removal shows AlertDialog. + +**Fix:** Add AlertDialog for role removal: "Remove role {name}? This may revoke access. Continue?" matching group removal pattern. + +**Files:** `pages/Admin/UsersTab.tsx:504-528` + +### 2b.6 Cancel Button Variant Standardization (MEDIUM) + +**Problem:** Create forms use `variant="ghost"`. Modal dialogs (TapConfigModal, RouteDetail) use `variant="secondary"`. + +**Fix:** Standardize Cancel = `ghost` everywhere. + +**Files:** `components/TapConfigModal.tsx:255`, `pages/Routes/RouteDetail.tsx` (tap modal footer) + +### 2b.7 OIDC Always-Editable Deviation (MEDIUM) + +**Problem:** Only admin form without Edit mode toggle or Cancel button. No way to discard changes. + +**Fix:** Add explicit Edit mode with Cancel/Save, matching Environment resource editing and AppConfigDetail patterns. Show read-only view by default, Edit button to enter edit mode. + +**Files:** `pages/Admin/OidcConfigPage.tsx` + +### 2b.8 ConfirmDialog Missing `loading` Prop (MEDIUM) + +**Problem:** `OidcConfigPage.tsx:258` and `RouteDetail.tsx:992` — no `loading` prop (all others have it). + +**Fix:** Add `loading={mutation.isPending}` to both. + +**Files:** `pages/Admin/OidcConfigPage.tsx:258`, `pages/Routes/RouteDetail.tsx:992` + +### 2b.9 Loading State Standardization (MEDIUM) + +**Problem:** Mix of `Spinner size="md"`, `Spinner size="lg"`, `PageLoader`, and `null`. + +**Fix:** Use `PageLoader` for all full-page loading states. Replace bare `` returns in UsersTab, GroupsTab, RolesTab, EnvironmentsPage, AppListView, AppDetailView with ``. Fix OidcConfigPage returning `null`. + +**Files:** All admin page files, `pages/Admin/OidcConfigPage.tsx` + +### 2b.10 Error Toast Title Format (MEDIUM) + +**Problem:** RBAC: "Failed to create user" / AppsTab: "Save failed" — two patterns. + +**Fix:** Standardize on "Failed to [verb] [noun]" (more descriptive). Update AppsTab and AppConfigDetailPage error toasts. + +**Files:** `pages/AppsTab/AppsTab.tsx`, `pages/Admin/AppConfigDetailPage.tsx` + +### 2b.11 Empty State Standardization (LOW) + +**Problem:** 5 different approaches. DS `EmptyState` component only used in AgentInstance. + +**Fix:** Replace all `

`, ``, `

`, and plain text empty states with DS `EmptyState` component or a consistent shared `emptyNote` class with centered, muted styling. + +**Files:** `pages/AppsTab/AppsTab.tsx`, `pages/Admin/*.tsx`, `pages/Routes/RouteDetail.tsx` + +### 2b.12 Unsaved Changes Indicator (LOW) + +**Problem:** Only AppsTab ConfigSubTab has the banner pattern. + +**Fix:** Add unsaved-changes indicator to: +- `AppConfigDetailPage.tsx` — reuse banner pattern from ConfigSubTab +- `EnvironmentsPage.tsx` — resource editing and JAR retention sections +- `OidcConfigPage.tsx` — after adding Edit mode (2b.7) + +### 2b.13 Save Button Loading State (LOW) + +**Problem:** `AppConfigDetailPage.tsx:313` shows "Saving..." text instead of Button's `loading` prop. + +**Fix:** Use `loading={isPending}` on Button (shows spinner, matches all other save buttons). + +**Files:** `pages/Admin/AppConfigDetailPage.tsx:313` + +### 2b.14 OIDC Dual Error Display (LOW) + +**Problem:** Shows both toast AND inline Alert on error. No other page does this. + +**Fix:** Remove the inline Alert. Use toast only, matching all other pages. Or remove the toast and keep only the inline Alert (useful for form context). Pick one — don't show both. + +**Files:** `pages/Admin/OidcConfigPage.tsx:92,139` + +--- + +## Batch 3: Contrast & Readability + +**Effort:** 1 day + +Reference: `UI_FINDINGS.md` cross-cutting issues + +### 3.1 WCAG AA Fix for `--text-muted` + +**Problem:** Fails WCAG AA in both modes. Light: #9C9184 on #FFFFFF = ~3.0:1. Dark: #7A7068 on #242019 = ~2.9:1. + +**Fix:** Change design system token values: +- Light mode: `--text-muted: #766A5E` (achieves 4.5:1) +- Dark mode: `--text-muted: #9A9088` (achieves 4.5:1) + +Single highest-impact fix — affects every page. + +**Files:** Design system CSS variables (light and dark theme definitions) + +### 3.2 WCAG AA Fix for `--text-faint` + +**Problem:** Dark mode #4A4238 on #242019 = 1.4:1 — essentially invisible. + +**Fix:** Restrict `--text-faint` to decorative use only (borders, dividers), or change dark mode value to `#6A6058` (achieves 3:1 minimum). Audit all usages and replace any `--text-faint` on readable text with `--text-muted`. + +**Files:** Design system CSS variables, all files using `--text-faint` + +### 3.3 Font Size Floor + +**Problem:** 10px text used for StatCard labels, overview labels, chain labels, section meta, sidebar tree labels. 11px for table meta, error messages, pagination, toggle buttons, chart titles. + +**Fix:** Establish `--font-size-min: 12px`. Update all 10px and 11px instances to 12px minimum. Grep for `font-size: 10px` and `font-size: 11px` across all CSS modules. + +**Files:** All CSS modules containing sub-12px font sizes + +--- + +## Batch 4: Data Formatting & Terminology + +**Effort:** 2 days + +### 4.1 Exchange ID Truncation + +**Problem:** 33-char hex dominates the table (e.g., `96E395B0088AA6D-000000000001E75C`). + +**Fix:** +- Show last 8 chars with ellipsis: `...0001E75C` +- Full ID on hover tooltip +- Copy-to-clipboard on click (show brief "Copied" indicator) +- Apply to: exchange table, detail breadcrumb, command palette results + +### 4.2 Attributes Column + +**Problem:** Always shows "---" for every row. + +**Fix:** Hide the column when all rows in the current result set have no attributes. Show as colored badges when populated (infrastructure exists in `attribute-color.ts`). + +### 4.3 Status Terminology + +**Problem:** "OK/ERR" in exchange table vs "COMPLETED/FAILED" in detail panel. + +**Fix:** Standardize on the table convention (OK/WARN/ERR) everywhere. Update the detail panel's status display. + +### 4.4 Agent Name Truncation + +**Problem:** Raw K8s pod names like `8c0affadb860-1` or `cameleer3-backend-7c778f488c-2c2pc-1`. + +**Fix:** +- Use agent `displayName` if set at registration +- Otherwise extract a short identifier (strip common prefix, show unique suffix) +- Full name in hover tooltip +- Apply to: exchange table Agent column, Runtime agent list + +### 4.5 Duration Formatting + +**Problem:** P99 "6695ms" should be "6.7s"; raw "321s" should be "5m 21s". + +**Fix:** Create/extend shared duration formatter: +- `< 1000ms` -> `Xms` (e.g., "178ms") +- `1000ms-59999ms` -> `X.Xs` (e.g., "6.7s") +- `>= 60000ms` -> `Xm Ys` (e.g., "5m 21s") + +Apply to: KPI strip, exchange table, exchange detail, dashboard route table, agent detail. + +### 4.6 Number Formatting + +**Problem:** Locale-specific decimals ("1.050" ambiguous), inconsistent unit spacing. + +**Fix:** Use `Intl.NumberFormat` with explicit locale handling. Always put space before unit: "6.7 s", "1.9 %", "7.1 msg/s". Use K/M suffixes consistently for large numbers. + +--- + +## Batch 5: Chart & Visualization Fixes + +**Effort:** 1 day + +### 5.1 Agent Throughput Y-axis + +**Problem:** 2 msg/s data on 1.2k scale — flat line. + +**Fix:** Auto-scale Y-axis to data range with ~20% headroom. Apply to all 6 agent charts. + +**Files:** `pages/AgentInstance/AgentInstance.tsx` + +### 5.2 Agent Error Rate Unit Mismatch + +**Problem:** Chart shows "err/h", KPI shows "%". + +**Fix:** Standardize units. If KPI shows %, chart should show %. + +### 5.3 Agent Memory Reference Line + +**Problem:** Y-axis goes to 68 MB but max heap is 124 MB. + +**Fix:** Add a reference/threshold line at max heap so users see how close memory is to the limit. + +### 5.4 Agent State "UNKNOWN" + +**Problem:** Shown alongside "LIVE" — confusing dual state. + +**Fix:** If UNKNOWN is a secondary state field, either hide when primary state is LIVE, or label clearly: "Container: Unknown". + +### 5.5 Dashboard Table Pointer Events + +**Problem:** `_tableSection` and `_chartGrid` divs intercept pointer events on Application Health table rows. + +**Fix:** Fix z-index/pointer-events so table rows receive click events correctly. + +**Files:** `pages/DashboardTab/DashboardTab.module.css` + +--- + +## Batch 6: Admin Polish + +**Effort:** 2 days + +### 6.1 Error Toasts Surface API Details + +**Problem:** All API error handlers show generic "Failed to X" without the response body message. + +**Fix:** Extract response body message in all API error handlers and include in toast description. Fallback to generic only when body is empty. + +### 6.2 Unicode Escape in Roles + +**Problem:** Role descriptions show `\u00b7` literally. + +**Fix:** Decode unicode escapes in the role description strings. Either fix at the backend (return actual character) or frontend (decode before render). + +**Files:** `pages/Admin/RolesTab.tsx`, potentially backend `RoleAdminController` + +### 6.3 Password Confirmation Field + +**Problem:** User creation form has no password confirmation. + +**Fix:** Add "Confirm Password" field below Password. Inline validation error if mismatch. Show password policy hint: "Min 12 characters, 3 of 4: uppercase, lowercase, number, special". + +**Files:** `pages/Admin/UsersTab.tsx` + +### 6.4 Platform Label/Value Spacing + +**Problem:** "Slugdefault", "Max Agents3", "Issued8. April 2026" — missing separators. + +**Fix:** Fix the SaaS platform components that render key-value pairs. Add proper layout (colon + space, or definition list). + +**Files:** SaaS platform UI (likely in `cameleer-saas` repo) + +### 6.5 License Badge Colors + +**Problem:** DISABLED features use red badges — looks like errors. + +**Fix:** Change from `--error` (red) to neutral gray/muted for "not included in plan". Reserve red for "broken"/"failed". + +**Files:** SaaS platform UI (license page component) + +### 6.6 OIDC Client Secret Masking + +**Problem:** Plain text input for sensitive field. + +**Fix:** Change to `type="password"` with a show/hide toggle button. + +**Files:** `pages/Admin/OidcConfigPage.tsx` + +### 6.7 Audit Log Export + +**Problem:** No export functionality for compliance. + +**Fix:** Add export button to audit log header: CSV (current page, client-side) and CSV (all matching, server-side streaming). Filename: `cameleer-audit-YYYY-MM-DDTHH-MM.csv`. + +**Files:** `pages/Admin/AuditLogPage.tsx`, new backend endpoint with `Accept: text/csv` + +--- + +## Batch 7: Nice-to-Have Polish + +**Effort:** 1-2 days + +Lower priority, implement time-permitting: + +| # | Item | File(s) | +|---|------|---------| +| 7.1 | Breadcrumb update when exchange selected | `ExchangesPage.tsx` | +| 7.2 | Explicit close/back button on exchange detail panel | `ExchangesPage.tsx` | +| 7.3 | Command palette: update category counts when search filters | Command palette component | +| 7.4 | Command palette: truncate exchange IDs | Command palette component | +| 7.5 | 7-Day Pattern heatmap: show "Insufficient data" when < 2 days | `DashboardTab` | +| 7.6 | Deployment list: show status badges per row | `AppsTab.tsx` | +| 7.7 | SplitPane empty state placeholders: add icon + centered styling | DS `SplitPane` or per-page | +| 7.8 | App deletion: make "Delete App" button more discoverable | `AppsTab.tsx` | +| 7.9 | Audit log: expandable rows for event detail | `AuditLogPage.tsx` | +| 7.10 | ConfirmDialog `confirmText` convention: standardize on display name | All ConfirmDialog usages | + +--- + +## Implementation Order + +| Order | Batch | Items | Effort | Impact | +|-------|-------|-------|--------|--------| +| 1 | **Batch 1: Critical Bugs** | 4 | 1 day | Fixes broken functionality | +| 2 | **Batch 2a: Layout Consistency** | 7 | 2 days | Visual coherence | +| 3 | **Batch 2b: Interaction Consistency** | 14 | 2 days | Behavioral coherence | +| 4 | **Batch 3: Contrast & Readability** | 3 | 1 day | WCAG compliance | +| 5 | **Batch 4: Data Formatting** | 6 | 2 days | Exchange table transformation | +| 6 | **Batch 5: Chart Fixes** | 5 | 1 day | Agent detail usability | +| 7 | **Batch 6: Admin Polish** | 7 | 2 days | Form UX and error handling | +| 8 | **Batch 7: Nice-to-Have** | 10 | 1-2 days | Final polish | + +**Total: ~52 items across 8 batches, ~12-14 days of work.** + +--- + +## Related Issues + +| Issue | Relevance | +|-------|-----------| +| #100 | Epic: UX Audit PMF Readiness — this spec covers polish/bugs only, not feature work | +| #105 | Exchange table readability — covered by Batch 4 (4.1-4.4) | +| #110 | Time/locale formatting — covered by Batch 4 (4.5-4.6) | +| #107 | Data export — partially covered by Batch 6 (6.7 audit log export) | +| #82 | Non-admin user experience — not covered (feature work) | +| #90 | Backend gaps — not covered (feature work) | + +## Appendix: Design System Reference Patterns + +**Correct table pattern** (Audit Log, ClickHouse, Dashboard L2/L3): +```tsx +import tableStyles from '../../styles/table-section.module.css'; + +
+
+ Title + meta +
+ +
+``` + +**Correct form section pattern** (OIDC, AppConfigDetail): +```tsx +import sectionStyles from '../../styles/section-card.module.css'; + +
+ Section Title + {/* form controls */} +
+``` + +**Correct button order** (all create/edit forms): +```tsx +
+ + +
+``` + +**Correct loading state:** +```tsx +import { PageLoader } from '../../components/PageLoader'; + +if (isLoading) return ; +``` From 80bc092ec1895fad1820630fde25cdd526f2d148 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:13:41 +0200 Subject: [PATCH 02/18] Add UX polish implementation plan (19 tasks across 8 batches) Detailed step-by-step plan covering critical bug fixes, layout/interaction consistency, WCAG contrast compliance, data formatting, chart fixes, and admin polish. Each task includes exact file paths, code snippets, and verification steps. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-09-ux-polish-plan.md | 1688 +++++++++++++++++ 1 file changed, 1688 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-09-ux-polish-plan.md diff --git a/docs/superpowers/plans/2026-04-09-ux-polish-plan.md b/docs/superpowers/plans/2026-04-09-ux-polish-plan.md new file mode 100644 index 00000000..8dbefa39 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-ux-polish-plan.md @@ -0,0 +1,1688 @@ +# UX Polish & Bug Fixes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix critical UI bugs, standardize layout/interaction patterns across all pages, improve contrast/readability, and polish data formatting and admin UX. + +**Architecture:** All changes are in the existing UI codebase (`ui/src/`) and one backend controller. No new components or architecture changes — this is about adopting existing design system patterns consistently and fixing bugs. The design system package `@cameleer/design-system` provides shared CSS modules and components that many pages don't yet use. + +**Tech Stack:** React 18, TypeScript, CSS Modules, `@cameleer/design-system`, React Router v6, React Query, Spring Boot (backend) + +**Spec:** `docs/superpowers/specs/2026-04-09-ux-polish-design.md` + +**Audit artifacts:** `audit/design-consistency-findings.md`, `audit/interaction-patterns-findings.md`, `audit/monitoring-pages-findings.md`, `audit/admin-lifecycle-findings.md` + +--- + +## Task 1: Fix `/server/deployments` 404 and GC Pauses chart + +**Spec items:** 1.3, 1.4 + +**Files:** +- Modify: `ui/src/router.tsx` +- Modify: `ui/src/pages/AgentInstance/AgentInstance.tsx` + +- [ ] **Step 1: Add deployments redirect in router.tsx** + +In `ui/src/router.tsx`, add a redirect route for `/server/deployments`. Find the existing legacy redirects (around lines 63-67 where `logs` redirects to `/runtime` and `config` redirects to `/apps`). Add a `deployments` redirect in the same block: + +```tsx +{ path: 'deployments', element: }, +``` + +Add this alongside the existing legacy redirects. The `Navigate` component should already be imported from `react-router-dom`. + +- [ ] **Step 2: Fix GC Pauses chart X-axis in AgentInstance.tsx** + +In `ui/src/pages/AgentInstance/AgentInstance.tsx`, find the GC Pauses series builder (around line 113): + +```typescript +// BEFORE (line 113): +return [{ label: 'GC ms', data: pts.map((p: any) => ({ x: String(p.time ?? ''), y: p.value })) }]; +``` + +Change to use numeric index like all other charts: + +```typescript +// AFTER: +return [{ label: 'GC ms', data: pts.map((p: any, i: number) => ({ x: i, y: p.value })) }]; +``` + +This matches the pattern used by CPU (line 95), Heap (line 101), Threads (line 107), Throughput (line 120), and Error Rate (line 127) — all use `x: i`. + +- [ ] **Step 3: Verify visually** + +Open the app in a browser: +1. Navigate to `https:///server/deployments` — should redirect to `/server/apps` +2. Navigate to Runtime > click an agent > scroll to GC Pauses chart — X-axis should show numeric labels, not ISO timestamps + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/router.tsx ui/src/pages/AgentInstance/AgentInstance.tsx +git commit -m "fix: add /deployments redirect and fix GC Pauses chart X-axis" +``` + +--- + +## Task 2: Fix user creation in OIDC mode + +**Spec items:** 1.2 + +**Files:** +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java` +- Modify: `ui/src/pages/Admin/UsersTab.tsx` + +- [ ] **Step 1: Run impact analysis on UserAdminController.createUser** + +```bash +npx gitnexus impact --target "UserAdminController.createUser" --direction upstream +``` + +Review blast radius before editing. + +- [ ] **Step 2: Fix backend — return error body when OIDC enabled** + +In `UserAdminController.java`, find the OIDC check (around line 92-93): + +```java +// BEFORE: +if (oidcEnabled) { + return ResponseEntity.badRequest().build(); +} +``` + +Change to return a descriptive error: + +```java +// AFTER: +if (oidcEnabled) { + return ResponseEntity.badRequest() + .body(java.util.Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO.")); +} +``` + +- [ ] **Step 3: Fix frontend — hide create form when OIDC local creation would fail** + +In `ui/src/pages/Admin/UsersTab.tsx`, the `+ Add user` button is on the `EntityList` component (around line 318). The OIDC config state needs to be checked. Find where OIDC state is available (it may already be fetched for the Local/OIDC radio toggle). + +Add a check: when the OIDC provider is enabled AND the user tries to create a `local` user, show a message instead of the form. The simplest approach: keep the form but when `newProvider === 'local'` and OIDC is the only provider, show an info callout explaining local creation is disabled. The existing `InfoCallout` for OIDC users (lines 247-251) provides the pattern. + +After the password field (around line 246), add a check for the case where local creation fails: + +```tsx +{newProvider === 'local' && oidcEnabled && ( + + Local user creation is disabled while OIDC is enabled. + Switch to OIDC to pre-register a user, or disable OIDC first. + +)} +``` + +Also update the error toast handler (in `handleCreate`) to surface the API error message. Find the catch block and use the response body: + +```typescript +onError: (err: any) => { + const message = err?.body?.error || err?.message || 'Unknown error'; + toast({ title: 'Failed to create user', description: message, variant: 'error', duration: 86_400_000 }); +}, +``` + +- [ ] **Step 4: Verify visually** + +1. Navigate to Admin > Users & Roles +2. Click "+ Add user", select "Local" provider +3. If OIDC is enabled, the info callout should appear +4. Attempting to create should show the descriptive error message, not just "Failed to create user" + +- [ ] **Step 5: Commit** + +```bash +git add cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java ui/src/pages/Admin/UsersTab.tsx +git commit -m "fix: show descriptive error when creating local user with OIDC enabled" +``` + +--- + +## Task 3: Investigate and fix SSE navigation bug + +**Spec item:** 1.1 + +**Files:** +- Investigate: `ui/src/components/LayoutShell.tsx` +- Investigate: `ui/src/pages/Exchanges/ExchangesPage.tsx` +- Investigate: `ui/src/hooks/` (any SSE or polling hooks) + +- [ ] **Step 1: Identify the navigation trigger** + +The audit found that admin pages sporadically redirect to `/server/exchanges`. LayoutShell.tsx has a path normalization at line 444-449: + +```typescript +const effectiveSelectedPath = useMemo(() => { + const raw = sidebarRevealPath ?? location.pathname; + const match = raw.match(/^\/(exchanges|dashboard|apps|runtime)\/([^/]+)(\/.*)?$/); + if (match) return `/exchanges/${match[2]}${match[3] ?? ''}`; + return raw; +}, [sidebarRevealPath, location.pathname]); +``` + +This rewrites ALL tab paths to `/exchanges/...` for sidebar highlighting. But this is a `useMemo`, not a navigation call. Search for: + +1. Any `useNavigate()` or `navigate()` calls triggered by data updates +2. Any `useEffect` that calls `navigate` based on exchange/catalog data changes +3. Any auto-refresh callback that might trigger navigation +4. The `sidebarRevealPath` state — what sets it? + +```bash +cd ui/src && grep -rn "navigate(" components/LayoutShell.tsx | head -20 +cd ui/src && grep -rn "sidebarRevealPath" components/LayoutShell.tsx | head -10 +``` + +- [ ] **Step 2: Apply fix based on investigation** + +The exact fix depends on what Step 1 reveals. The principle: SSE/polling data updates must NEVER trigger `navigate()` when the user is on an admin page. Common patterns to look for: + +- A `useEffect` that watches exchange data and navigates to show the latest exchange +- A sidebar tree item click handler that fires on data refresh (re-render causes focus/activation) +- An auto-refresh timer that resets the route + +If it's a `useEffect` with `navigate`, add a route guard: + +```typescript +// Only navigate if we're already on the exchanges tab +if (!location.pathname.startsWith('/server/exchanges')) return; +``` + +If it's a sidebar focus issue, prevent navigation on programmatic focus: + +```typescript +// Only navigate on explicit user clicks, not focus events +onClick={(e) => { if (e.isTrusted) navigate(path); }} +``` + +- [ ] **Step 3: Verify by navigating admin pages while data is flowing** + +1. Open Admin > Users & Roles +2. Wait 30-60 seconds while agents are sending data +3. Interact with the form (click fields, open dropdowns) +4. Confirm no redirect to /server/exchanges occurs + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/components/LayoutShell.tsx # and any other modified files +git commit -m "fix: prevent SSE data updates from triggering navigation on admin pages" +``` + +--- + +## Task 4: Exchanges table containment and Dashboard padding + +**Spec items:** 2a.1, 2a.4 + +**Files:** +- Modify: `ui/src/pages/Dashboard/Dashboard.tsx` +- Modify: `ui/src/pages/Dashboard/Dashboard.module.css` +- Modify: `ui/src/pages/DashboardTab/DashboardTab.module.css` +- Modify: `ui/src/pages/AppsTab/AppsTab.module.css` + +- [ ] **Step 1: Wrap exchanges table in shared table-section** + +In `ui/src/pages/Dashboard/Dashboard.tsx`, add the shared table-section import: + +```typescript +import tableStyles from '../../styles/table-section.module.css'; +``` + +Find the table rendering section (around line 237-285). Wrap the table header and DataTable in the shared `tableSection`: + +```tsx +
+
+ Recent Exchanges +
+ {exchanges.length} of {formatNumber(total)} exchanges + {/* existing auto-refresh indicator */} +
+
+ +
+``` + +Replace the custom `.tableHeader`, `.tableTitle`, `.tableRight`, `.tableMeta` class usages with the shared module equivalents. + +- [ ] **Step 2: Remove custom table classes from Dashboard.module.css** + +In `ui/src/pages/Dashboard/Dashboard.module.css`, remove the custom `.tableHeader`, `.tableTitle`, `.tableRight`, `.tableMeta` classes (they're now provided by the shared module). Keep any other custom classes that aren't table-related. + +- [ ] **Step 3: Add side padding to DashboardTab** + +In `ui/src/pages/DashboardTab/DashboardTab.module.css`, update `.content`: + +```css +/* BEFORE: */ +.content { + display: flex; + flex-direction: column; + gap: 20px; + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 20px; +} + +/* AFTER: */ +.content { + display: flex; + flex-direction: column; + gap: 20px; + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 24px 20px; +} +``` + +- [ ] **Step 4: Normalize AppsTab container padding** + +In `ui/src/pages/AppsTab/AppsTab.module.css`, update `.container`: + +```css +/* BEFORE: */ +.container { + padding: 16px; + overflow-y: auto; + flex: 1; +} + +/* AFTER: */ +.container { + padding: 20px 24px 40px; + overflow-y: auto; + flex: 1; +} +``` + +- [ ] **Step 5: Verify visually** + +1. Exchanges tab: table should have card wrapper with border/shadow, matching Audit Log style +2. Dashboard: content should have side margins (24px), no longer flush against sidebar +3. Deployments tab: spacing should match Admin pages (20px top, 24px sides) + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/pages/Dashboard/Dashboard.tsx ui/src/pages/Dashboard/Dashboard.module.css ui/src/pages/DashboardTab/DashboardTab.module.css ui/src/pages/AppsTab/AppsTab.module.css +git commit -m "fix: standardize table containment and container padding across pages" +``` + +--- + +## Task 5: App detail section cards and deployment DataTable + +**Spec items:** 2a.2, 2a.3 + +**Files:** +- Modify: `ui/src/pages/AppsTab/AppsTab.tsx` +- Modify: `ui/src/pages/AppsTab/AppsTab.module.css` + +- [ ] **Step 1: Import shared section-card and table-section modules** + +At the top of `ui/src/pages/AppsTab/AppsTab.tsx`, add: + +```typescript +import sectionStyles from '../../styles/section-card.module.css'; +import tableStyles from '../../styles/table-section.module.css'; +``` + +- [ ] **Step 2: Wrap config sub-tab content in section cards** + +Find each configuration group in the `ConfigSubTab` component (around lines 722-860). Each logical section (Monitoring settings, Resources, Variables, etc.) should be wrapped in a section card: + +```tsx +
+ Monitoring + {/* existing monitoring controls: Engine Level, Payload Capture, etc. */} +
+``` + +Apply this to each sub-tab's content area. The existing `SectionHeader` components mark where sections begin — wrap each section header + its controls in `sectionStyles.section`. + +- [ ] **Step 3: Replace manual `
` with DataTable in OverviewSubTab** + +Find the manual `
` in `OverviewSubTab` (lines 623-680). Replace with: + +```tsx +
+
+ Deployments +
+ {row.environmentSlug} }, + { key: 'version', header: 'Version' }, + { key: 'status', header: 'Status', render: (_, row) => {row.status} }, + { key: 'deployStage', header: 'Deploy Stage' }, + { key: 'actions', header: '', render: (_, row) => ( + row.status === 'RUNNING' || row.status === 'STARTING' ? ( + + ) : null + )}, + ]} + data={deployments} + emptyMessage="No deployments yet." + /> +
+``` + +Adapt the column definitions to match the existing manual table columns. Import `DataTable` from `@cameleer/design-system` if not already imported. + +- [ ] **Step 4: Remove custom `.table` styles from AppsTab.module.css** + +Remove the manual table CSS classes (`.table`, `.table th`, `.table td`, etc.) from `AppsTab.module.css` since they're replaced by DataTable + shared table-section. + +- [ ] **Step 5: Verify visually** + +1. Navigate to Deployments tab > click an app +2. Configuration sections should have card wrappers (border, shadow, background) +3. Deployment table should use DataTable with card wrapper, matching other tables + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/pages/AppsTab/AppsTab.tsx ui/src/pages/AppsTab/AppsTab.module.css +git commit -m "fix: wrap app config in section cards, replace manual table with DataTable" +``` + +--- + +## Task 6: Deduplicate card CSS and wrap remaining flat content + +**Spec items:** 2a.5, 2a.6, 2a.7 + +**Files:** +- Modify: `ui/src/pages/DashboardTab/DashboardTab.module.css` +- Modify: `ui/src/pages/DashboardTab/DashboardL1.tsx` (or L2/L3 — whichever uses errorsSection/diagramSection) +- Modify: `ui/src/pages/AgentHealth/AgentHealth.tsx` +- Modify: `ui/src/pages/AgentHealth/AgentHealth.module.css` +- Modify: `ui/src/pages/AgentInstance/AgentInstance.tsx` +- Modify: `ui/src/pages/AgentInstance/AgentInstance.module.css` +- Modify: `ui/src/pages/Admin/ClickHouseAdminPage.module.css` +- Modify: `ui/src/pages/Admin/ClickHouseAdminPage.tsx` +- Modify: `ui/src/pages/Admin/DatabaseAdminPage.tsx` +- Modify: `ui/src/pages/Admin/UsersTab.tsx` +- Modify: `ui/src/pages/Admin/GroupsTab.tsx` +- Modify: `ui/src/pages/Admin/EnvironmentsPage.tsx` + +- [ ] **Step 1: Replace duplicated card CSS in DashboardTab** + +In `DashboardTab.module.css`, the `.errorsSection` and `.diagramSection` classes duplicate the card pattern. In the TSX files that use them, replace with the shared module: + +```typescript +import tableStyles from '../../styles/table-section.module.css'; +``` + +Replace `className={styles.errorsSection}` with `className={tableStyles.tableSection}` (since these are table-like sections). Remove `.errorsSection` and `.diagramSection` from `DashboardTab.module.css`. Keep any non-card properties (like `height: 280px` on diagramSection) as a separate class composed with the shared one: + +```tsx +
+``` + +```css +.diagramHeight { height: 280px; } +``` + +- [ ] **Step 2: Replace duplicated card CSS in AgentHealth** + +In `AgentHealth.module.css`, remove the card pattern from `.configBar` and `.eventCard`. Import `sectionStyles`: + +```typescript +import sectionStyles from '../../styles/section-card.module.css'; +``` + +Replace `className={styles.configBar}` with `className={sectionStyles.section}` (keep any custom padding/margin in a composed class if needed). Same for `.eventCard`. + +- [ ] **Step 3: Replace duplicated card CSS in AgentInstance** + +Same pattern for `.processCard` and `.timelineCard` in `AgentInstance.module.css`. Import `sectionStyles` and replace. + +- [ ] **Step 4: Replace duplicated card CSS in ClickHouseAdminPage** + +Replace `.pipelineCard` with `sectionStyles.section`. + +- [ ] **Step 5: Wrap Database admin tables in tableSection** + +In `DatabaseAdminPage.tsx`, import `tableStyles` and wrap each `DataTable` in a `tableStyles.tableSection` div with a `tableStyles.tableHeader`. + +- [ ] **Step 6: Wrap RBAC and Environments detail sections in section cards** + +In `UsersTab.tsx`, `GroupsTab.tsx`, and `EnvironmentsPage.tsx`, import `sectionStyles` and wrap detail panel sections in `sectionStyles.section`. Each section header + its content becomes a card: + +```tsx +import sectionStyles from '../../styles/section-card.module.css'; + +// In the detail panel: +
+ Group Membership + {/* existing membership tags */} +
+ +
+ Effective Roles + {/* existing role tags */} +
+``` + +- [ ] **Step 7: Verify visually** + +1. Dashboard: errors section and diagram section should still look the same (card styling from shared module now) +2. Runtime > Agent detail: process card and timeline card should have consistent card styling +3. Admin > Database: tables should have card wrappers +4. Admin > Users & Roles: detail panel sections should have card backgrounds +5. Admin > Environments: detail panel sections should have card backgrounds + +- [ ] **Step 8: Commit** + +```bash +git add -A ui/src/pages/DashboardTab/ ui/src/pages/AgentHealth/ ui/src/pages/AgentInstance/ ui/src/pages/Admin/ClickHouseAdminPage.* ui/src/pages/Admin/DatabaseAdminPage.* ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Admin/GroupsTab.tsx ui/src/pages/Admin/EnvironmentsPage.tsx +git commit -m "fix: deduplicate card CSS, use shared section-card and table-section modules" +``` + +--- + +## Task 7: Button order, confirmation dialogs, and destructive action guards + +**Spec items:** 2b.1, 2b.2, 2b.3, 2b.4, 2b.5, 2b.6, 2b.8 + +**Files:** +- Modify: `ui/src/pages/Admin/AppConfigDetailPage.tsx` +- Modify: `ui/src/pages/AppsTab/AppsTab.tsx` +- Modify: `ui/src/components/TapConfigModal.tsx` +- Modify: `ui/src/pages/Admin/DatabaseAdminPage.tsx` +- Modify: `ui/src/pages/Admin/UsersTab.tsx` +- Modify: `ui/src/pages/Routes/RouteDetail.tsx` +- Modify: `ui/src/pages/Admin/OidcConfigPage.tsx` + +- [ ] **Step 1: Fix AppConfigDetailPage button order** + +In `ui/src/pages/Admin/AppConfigDetailPage.tsx`, find lines 310-319. Change from Save|Cancel to Cancel|Save with correct variants: + +```tsx +{editing ? ( +
+ + +
+) : ( + +)} +``` + +This also fixes spec items 2b.13 (uses `loading` prop instead of "Saving..." text). + +- [ ] **Step 2: Add confirmation dialog for deployment stop** + +In `ui/src/pages/AppsTab/AppsTab.tsx`, find `handleStop` (around line 526). Add state for the confirmation dialog: + +```typescript +const [stopTarget, setStopTarget] = useState<{ id: string; name: string } | null>(null); +``` + +Replace the immediate stop with a dialog trigger: + +```tsx +// In the OverviewSubTab, change the Stop button to: + + +// Add ConfirmDialog near the bottom of the component: + setStopTarget(null)} + onConfirm={async () => { + if (!stopTarget) return; + try { + await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id }); + toast({ title: 'Deployment stopped', variant: 'warning' }); + } catch { + toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 }); + } + setStopTarget(null); + }} + title="Stop deployment?" + message={`Stop deployment for "${stopTarget?.name}"? This will take the service offline.`} + confirmText={appSlug} + loading={stopDeployment.isPending} +/> +``` + +Import `ConfirmDialog` from `@cameleer/design-system` if not already imported. + +- [ ] **Step 3: Add confirmation dialog for tap deletion in TapConfigModal** + +In `ui/src/components/TapConfigModal.tsx`, find the delete handler (around line 117). Add state: + +```typescript +const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); +``` + +Change the delete button (around line 249) from immediate to dialog trigger: + +```tsx + + + setShowDeleteConfirm(false)} + onConfirm={() => { + onDelete?.(); + setShowDeleteConfirm(false); + onClose(); + }} + title="Delete tap?" + message={`Delete tap "${attributeName}"? This will remove it from the configuration.`} + confirmText={attributeName} +/> +``` + +- [ ] **Step 4: Add AlertDialog for kill query in DatabaseAdminPage** + +In `ui/src/pages/Admin/DatabaseAdminPage.tsx`, find the Kill button (around line 30). Add state and dialog: + +```typescript +const [killTarget, setKillTarget] = useState(null); +``` + +```tsx + + + setKillTarget(null)} + onConfirm={async () => { + try { + await killQuery(killTarget!); + toast({ title: 'Query killed', variant: 'warning' }); + } catch { + toast({ title: 'Failed to kill query', variant: 'error', duration: 86_400_000 }); + } + setKillTarget(null); + }} + title="Kill query?" + description={`This will terminate the running query (PID: ${killTarget}). Continue?`} + confirmLabel="Kill" + variant="warning" +/> +``` + +Import `AlertDialog` from `@cameleer/design-system`. + +- [ ] **Step 5: Add AlertDialog for role removal from user** + +In `ui/src/pages/Admin/UsersTab.tsx`, find the role tag `onRemove` handler (around line 508-526). Add state: + +```typescript +const [removeRoleTarget, setRemoveRoleTarget] = useState<{ userId: string; roleId: string; roleName: string } | null>(null); +``` + +Change the `onRemove` to open a dialog instead of immediate mutation: + +```tsx +onRemove={() => setRemoveRoleTarget({ userId: selected.userId, roleId: r.id, roleName: r.name })} +``` + +Add the AlertDialog: + +```tsx + setRemoveRoleTarget(null)} + onConfirm={() => { + if (!removeRoleTarget) return; + removeRole.mutate( + { userId: removeRoleTarget.userId, roleId: removeRoleTarget.roleId }, + { + onSuccess: () => toast({ title: 'Role removed', description: removeRoleTarget.roleName, variant: 'success' }), + onError: () => toast({ title: 'Failed to remove role', variant: 'error', duration: 86_400_000 }), + }, + ); + setRemoveRoleTarget(null); + }} + title="Remove role?" + description={`Remove role "${removeRoleTarget?.roleName}"? This may revoke access for this user. Continue?`} + confirmLabel="Remove" + variant="warning" +/> +``` + +- [ ] **Step 6: Standardize Cancel button variant to ghost** + +In `ui/src/components/TapConfigModal.tsx` (around line 255), change: +```tsx +// BEFORE: + +// AFTER: + +``` + +In `ui/src/pages/Routes/RouteDetail.tsx`, find the tap modal footer Cancel button and change `variant="secondary"` to `variant="ghost"`. + +- [ ] **Step 7: Add loading prop to ConfirmDialogs that lack it** + +In `ui/src/pages/Admin/OidcConfigPage.tsx` (around line 258), find the ConfirmDialog for OIDC delete. Add loading prop — track the delete operation state: + +```tsx + +``` + +In `ui/src/pages/Routes/RouteDetail.tsx` (around line 992), find the tap delete ConfirmDialog. Add `loading` prop if a mutation state is available. + +- [ ] **Step 8: Verify** + +1. AppConfigDetailPage: Edit mode shows Cancel (left) | Save (right, primary, with spinner) +2. Deployments: Stop button shows type-to-confirm dialog +3. TapConfigModal: Delete shows confirmation dialog +4. Database: Kill shows AlertDialog with warning +5. Users: Removing a role shows AlertDialog +6. All Cancel buttons use ghost variant + +- [ ] **Step 9: Commit** + +```bash +git add ui/src/pages/Admin/AppConfigDetailPage.tsx ui/src/pages/AppsTab/AppsTab.tsx ui/src/components/TapConfigModal.tsx ui/src/pages/Admin/DatabaseAdminPage.tsx ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Routes/RouteDetail.tsx ui/src/pages/Admin/OidcConfigPage.tsx +git commit -m "fix: standardize button order, add confirmation dialogs for destructive actions" +``` + +--- + +## Task 8: OIDC edit mode, loading states, and error toast format + +**Spec items:** 2b.7, 2b.9, 2b.10, 2b.13, 2b.14 + +**Files:** +- Modify: `ui/src/pages/Admin/OidcConfigPage.tsx` +- Modify: `ui/src/pages/Admin/UsersTab.tsx` +- Modify: `ui/src/pages/Admin/GroupsTab.tsx` +- Modify: `ui/src/pages/Admin/RolesTab.tsx` +- Modify: `ui/src/pages/Admin/EnvironmentsPage.tsx` +- Modify: `ui/src/pages/AppsTab/AppsTab.tsx` +- Modify: `ui/src/pages/Admin/AppConfigDetailPage.tsx` + +- [ ] **Step 1: Add edit mode to OidcConfigPage** + +In `ui/src/pages/Admin/OidcConfigPage.tsx`, add editing state: + +```typescript +const [editing, setEditing] = useState(false); +const [formDraft, setFormDraft] = useState(null); + +function startEditing() { + setFormDraft(form ? { ...form } : null); + setEditing(true); +} + +function cancelEditing() { + setFormDraft(null); + setEditing(false); + setError(null); +} +``` + +Update the toolbar (around line 130-137): +```tsx +{editing ? ( + <> + + + +) : ( + <> + + + +)} +``` + +Make all form fields read-only when `!editing`. The form should display values from `form` in read mode and `formDraft` in edit mode. Update `handleSave` to use `formDraft` and call `cancelEditing` on success (after updating `form` with saved data). + +Remove the inline `` (line 138-139) — keep only the toast for errors (fixes 2b.14). + +- [ ] **Step 2: Replace Spinner with PageLoader across admin pages** + +In each of these files, replace bare `` returns with ``: + +**UsersTab.tsx** — find `if (usersLoading) return ;` and replace: +```typescript +import { PageLoader } from '../../components/PageLoader'; +// ... +if (usersLoading) return ; +``` + +**GroupsTab.tsx** — same pattern: +```typescript +import { PageLoader } from '../../components/PageLoader'; +if (groupsLoading) return ; +``` + +**RolesTab.tsx:** +```typescript +import { PageLoader } from '../../components/PageLoader'; +if (rolesLoading) return ; +``` + +**EnvironmentsPage.tsx:** +```typescript +import { PageLoader } from '../../components/PageLoader'; +if (envsLoading) return ; +``` + +**OidcConfigPage.tsx** — currently returns `null`, change to: +```typescript +import { PageLoader } from '../../components/PageLoader'; +if (!form) return ; +``` + +**AppsTab.tsx** (AppListView and AppDetailView) — find `` returns and replace with ``. + +- [ ] **Step 3: Standardize error toast titles** + +In `ui/src/pages/AppsTab/AppsTab.tsx`, find error toasts with format "[Noun] failed" and change to "Failed to [verb] [noun]": + +- `'Save failed'` -> `'Failed to save configuration'` +- `'Upload failed'` -> `'Failed to upload JAR'` +- `'Deploy failed'` -> `'Failed to deploy application'` +- `'Stop failed'` -> `'Failed to stop deployment'` + +In `ui/src/pages/Admin/AppConfigDetailPage.tsx`: +- `'Save failed'` -> `'Failed to save configuration'` + +- [ ] **Step 4: Verify** + +1. OIDC page: starts in read-only mode, Edit button enters edit mode, Cancel discards changes +2. All admin pages show centered PageLoader spinner (not small inline Spinner) +3. Error toasts use "Failed to [verb] [noun]" format + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/pages/Admin/OidcConfigPage.tsx ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Admin/GroupsTab.tsx ui/src/pages/Admin/RolesTab.tsx ui/src/pages/Admin/EnvironmentsPage.tsx ui/src/pages/AppsTab/AppsTab.tsx ui/src/pages/Admin/AppConfigDetailPage.tsx +git commit -m "fix: add OIDC edit mode, standardize PageLoader and error toast format" +``` + +--- + +## Task 9: WCAG contrast fixes and font size floor + +**Spec items:** 3.1, 3.2, 3.3 + +**Files:** +- Modify: Design system theme CSS (find the file defining `--text-muted` and `--text-faint`) +- Modify: Multiple CSS modules (font-size changes) + +- [ ] **Step 1: Find where design system tokens are defined** + +```bash +cd ui && grep -rn "\-\-text-muted" node_modules/@cameleer/design-system/dist/ 2>/dev/null | head -5 +# Or check if tokens are defined in the project itself: +grep -rn "\-\-text-muted" src/ --include="*.css" | grep -v "module.css" | head -10 +``` + +If the tokens are in the design system package, they need to be overridden at the app level. If they're in a local theme file, edit directly. + +- [ ] **Step 2: Update --text-muted values** + +In the theme/token CSS file (wherever `--text-muted` is defined): + +```css +/* Light mode: */ +--text-muted: #766A5E; /* was #9C9184, now 4.5:1 on white */ + +/* Dark mode: */ +--text-muted: #9A9088; /* was #7A7068, now 4.5:1 on #242019 */ +``` + +- [ ] **Step 3: Update --text-faint dark mode value** + +```css +/* Dark mode: */ +--text-faint: #6A6058; /* was #4A4238 (1.4:1!), now 3:1 on #242019 */ +``` + +- [ ] **Step 4: Audit and fix --text-faint usage on readable text** + +```bash +grep -rn "text-faint" ui/src/ --include="*.css" --include="*.module.css" +``` + +For each usage, check if it's on readable text (not just decorative borders/dividers). If on readable text, change to `--text-muted`. + +- [ ] **Step 5: Fix all sub-12px font sizes** + +```bash +grep -rn "font-size: 10px\|font-size: 11px" ui/src/ --include="*.css" --include="*.module.css" +``` + +For each match, change to `font-size: 12px`. This includes StatCard labels, overview labels, table meta, sidebar tree labels, chart titles, pagination text, etc. + +- [ ] **Step 6: Verify visually** + +1. Check light mode: muted text should be noticeably darker +2. Check dark mode: muted text should be clearly readable, faint text should be visible +3. All labels and meta text should be at least 12px +4. Run a contrast checker browser extension to verify ratios + +- [ ] **Step 7: Commit** + +```bash +git add -A ui/src/ +git commit -m "fix: WCAG AA contrast compliance for --text-muted/--text-faint, 12px font floor" +``` + +--- + +## Task 10: Duration formatter and exchange ID truncation + +**Spec items:** 4.1, 4.5 + +**Files:** +- Modify: `ui/src/utils/format-utils.ts` +- Modify: `ui/src/pages/Dashboard/Dashboard.tsx` + +- [ ] **Step 1: Improve the shared duration formatter** + +In `ui/src/utils/format-utils.ts`, find `formatDuration` (around line 1-5): + +```typescript +// BEFORE: +export function formatDuration(ms: number): string { + if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`; + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; + return `${ms}ms`; +} +``` + +Replace with: + +```typescript +// AFTER: +export function formatDuration(ms: number): string { + if (ms >= 60_000) { + 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`; +} +``` + +Changes: `>= 60s` now shows `Xm Ys` instead of raw seconds. `1-60s` now shows one decimal (`6.7s`) instead of two (`6.70s`). + +Also update `formatDurationShort` to match: + +```typescript +export function formatDurationShort(ms: number | undefined): string { + if (ms == null) return '-'; + if (ms >= 60_000) { + 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`; +} +``` + +- [ ] **Step 2: Truncate Exchange IDs in the table** + +In `ui/src/pages/Dashboard/Dashboard.tsx`, find the Exchange ID column (around line 114-116): + +```typescript +// BEFORE: +render: (_: unknown, row: Row) => ( + {row.executionId} +), +``` + +Change to truncated display with tooltip: + +```typescript +// AFTER: +render: (_: unknown, row: Row) => ( + { + e.stopPropagation(); + navigator.clipboard.writeText(row.executionId); + }}> + ...{row.executionId.slice(-8)} + +), +``` + +This shows the last 8 characters with an ellipsis prefix, the full ID on hover, and copies to clipboard on click. + +- [ ] **Step 3: Verify** + +1. Exchange table: IDs should show `...0001E75C` format +2. Hovering should show full ID +3. Clicking the ID should copy to clipboard +4. Durations: `321000ms` should show as `5m 21s`, `6700ms` as `6.7s`, `178ms` as `178ms` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/utils/format-utils.ts ui/src/pages/Dashboard/Dashboard.tsx +git commit -m "fix: improve duration formatting (Xm Ys) and truncate exchange IDs" +``` + +--- + +## Task 11: Attributes column, status terminology, and agent names + +**Spec items:** 4.2, 4.3, 4.4 + +**Files:** +- Modify: `ui/src/pages/Dashboard/Dashboard.tsx` +- Modify: `ui/src/utils/format-utils.ts` (if `statusLabel` is defined there) + +- [ ] **Step 1: Hide Attributes column when empty** + +In `ui/src/pages/Dashboard/Dashboard.tsx`, find the Attributes column definition (around line 96-108). Make it conditional based on whether any row has attributes: + +```typescript +const hasAttributes = exchanges.some(e => e.attributes && Object.keys(e.attributes).length > 0); +``` + +In the columns array, conditionally include the Attributes column: + +```typescript +const columns = [ + // ...status, route, application columns... + ...(hasAttributes ? [{ + key: 'attributes' as const, + header: 'Attributes', + render: (_: unknown, row: Row) => { + const attrs = row.attributes; + if (!attrs || Object.keys(attrs).length === 0) return ; + return ( + + {Object.entries(attrs).map(([k, v]) => ( + {k}={v} + ))} + + ); + }, + }] : []), + // ...Exchange ID, Started, Duration, Agent columns... +]; +``` + +- [ ] **Step 2: Standardize status labels** + +Find `statusLabel` in `ui/src/utils/format-utils.ts` (or wherever it's defined). It should map: + +```typescript +export function statusLabel(status: string): string { + switch (status) { + case 'COMPLETED': return 'OK'; + case 'FAILED': return 'ERR'; + case 'RUNNING': return 'RUN'; + default: return status; + } +} +``` + +Verify this function is used in BOTH the exchange table AND the exchange detail panel. If the detail panel uses raw `status` instead of `statusLabel()`, update it to use the same function. + +Search for where the detail panel displays status: +```bash +grep -rn "COMPLETED\|FAILED" ui/src/pages/Exchanges/ --include="*.tsx" +``` + +Update any raw status display to use `statusLabel()`. + +- [ ] **Step 3: Truncate agent names** + +In `ui/src/pages/Dashboard/Dashboard.tsx`, find the Agent column (around line 135-140). Add a truncation helper: + +```typescript +function shortAgentName(name: string): string { + // If name contains multiple dashes (K8s pod name pattern), take the last segment + const parts = name.split('-'); + if (parts.length >= 3) { + // Show last 2 segments: "8c0affadb860-1" from "cameleer3-sample-8c0affadb860-1" + return parts.slice(-2).join('-'); + } + return name; +} +``` + +Update the Agent column render: + +```typescript +render: (_: unknown, row: Row) => ( + {shortAgentName(row.agentId)} +), +``` + +- [ ] **Step 4: Verify** + +1. Exchange table: Attributes column should be hidden (all "---" currently) +2. Status shows "OK"/"ERR" in both table and detail panel +3. Agent names show truncated form with full name on hover + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/pages/Dashboard/Dashboard.tsx ui/src/utils/format-utils.ts ui/src/pages/Exchanges/ +git commit -m "fix: hide empty attributes column, standardize status labels, truncate agent names" +``` + +--- + +## Task 12: Chart Y-axis scaling and agent state display + +**Spec items:** 5.1, 5.2, 5.3, 5.4, 5.5 + +**Files:** +- Modify: `ui/src/pages/AgentInstance/AgentInstance.tsx` +- Modify: `ui/src/pages/DashboardTab/DashboardTab.module.css` + +- [ ] **Step 1: Fix agent chart Y-axis auto-scaling** + +In `ui/src/pages/AgentInstance/AgentInstance.tsx`, check how the chart components accept Y-axis configuration. The `LineChart` and `BarChart` components likely accept a `yMax` or `yDomain` prop. + +For the Throughput chart (around line 339), if it uses a fixed max, remove it or set it dynamically: + +```typescript +// If the chart has a yMax prop, compute it from data: +const throughputMax = Math.max(...throughputSeries[0].data.map(d => d.y), 1) * 1.2; +``` + +Pass this to the chart: `yMax={throughputMax}` + +Apply the same pattern to Error Rate (line 353) and all other charts. + +- [ ] **Step 2: Standardize Error Rate unit** + +Find the Error Rate chart (around line 353). Change `yLabel` from `"err/h"` to match the KPI display: + +```typescript +// BEFORE: + + +// AFTER: + +``` + +Ensure the error rate data is in percentage format (not absolute count). If the data is in errors/hour, convert: +```typescript +// Convert errors per hour to percentage: +const errorPctSeries = errorSeries.map(s => ({ + ...s, + data: s.data.map(d => ({ ...d, y: totalThroughput > 0 ? (d.y / totalThroughput * 100) : 0 })), +})); +``` + +- [ ] **Step 3: Add memory reference line** + +For the Memory chart (around line 325), add a reference line at max heap. Check if the chart component supports a `referenceLine` or `threshold` prop: + +```typescript + +``` + +If the chart component doesn't support reference lines, this may need to be deferred or the component extended. + +- [ ] **Step 4: Fix agent state "UNKNOWN" display** + +Find where the dual state (LIVE + UNKNOWN) is displayed. In the agent detail header area, there's likely a state badge showing both the agent state and a container state. + +If the secondary state is "UNKNOWN" while the primary is "LIVE", hide it: + +```tsx +{agent.state && agent.state !== 'UNKNOWN' && agent.state !== agent.primaryState && ( + {agent.state} +)} +``` + +Or add a label: `Container: {agent.containerState}` + +- [ ] **Step 5: Fix Dashboard table pointer events** + +In `ui/src/pages/DashboardTab/DashboardTab.module.css`, find `.chartGrid` or `._tableSection` classes. Add explicit pointer-events and z-index: + +```css +/* Ensure table rows are clickable above chart overlays */ +.tableSection { + position: relative; + z-index: 1; +} + +.chartGrid { + pointer-events: none; /* Don't intercept clicks meant for the table */ +} + +.chartGrid > * { + pointer-events: auto; /* But chart elements themselves are still interactive */ +} +``` + +The exact fix depends on the DOM structure. Inspect the layout to see which element is intercepting clicks. + +- [ ] **Step 6: Verify** + +1. Agent Throughput chart: Y-axis scales to actual data range (not 1.2k when data is ~2) +2. Agent Error Rate chart: shows "%" label +3. Agent Memory chart: shows reference line at max heap +4. Agent state: no confusing "UNKNOWN" alongside "LIVE" +5. Dashboard: Application Health table rows clickable without CSS interception + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/pages/AgentInstance/AgentInstance.tsx ui/src/pages/DashboardTab/DashboardTab.module.css +git commit -m "fix: chart Y-axis auto-scaling, error rate unit, memory reference line, pointer events" +``` + +--- + +## Task 13: Error toast API details, unicode fix, password confirmation + +**Spec items:** 6.1, 6.2, 6.3 + +**Files:** +- Modify: `ui/src/pages/Admin/UsersTab.tsx` +- Modify: `ui/src/pages/Admin/RolesTab.tsx` +- Modify: Multiple files with API error handlers + +- [ ] **Step 1: Surface API error details in toasts** + +Create a shared error extraction utility. In `ui/src/utils/format-utils.ts`, add: + +```typescript +export async function extractApiError(err: unknown): Promise { + if (err && typeof err === 'object') { + const e = err as any; + // If the error has a response body with a message + if (e.body?.error) return e.body.error; + if (e.body?.message) return e.body.message; + if (e.message) return e.message; + // Try to read the response body if it's a fetch Response + if (e.response?.text) { + try { + const text = await e.response.text(); + const json = JSON.parse(text); + return json.error || json.message || text; + } catch { return 'Unknown error'; } + } + } + return 'Unknown error'; +} +``` + +Update error toast handlers in key files to use the description field. For example in `UsersTab.tsx`: + +```typescript +onError: async (err) => { + const description = await extractApiError(err); + toast({ title: 'Failed to create user', description, variant: 'error', duration: 86_400_000 }); +}, +``` + +Apply this pattern to the most visible error handlers across RBAC pages, AppsTab, and AppConfigDetailPage. + +- [ ] **Step 2: Fix unicode escape in role descriptions** + +In `ui/src/pages/Admin/RolesTab.tsx`, find line 180: + +```typescript +// BEFORE: +{role.description || '\u2014'} \u00b7 {getAssignmentCount(role)} assignments +``` + +The `\u00b7` and `\u2014` in template literals should render correctly as actual characters. But if they're showing literally, it means they're likely being escaped somewhere upstream. Check if `role.description` contains literal `\u00b7` strings from the backend. + +If the backend returns literal `\\u00b7` (double-escaped), the fix is on the backend in the role seed data or the API serialization. If it's a frontend template issue, the existing code should work (JS template literals process unicode escapes at parse time). + +Check what the API returns: +```bash +curl -s -H "Authorization: Bearer " https:///api/v1/admin/roles | jq '.[].description' +``` + +If the backend returns literal `\u00b7`, fix the seed data or migration that creates the system roles. + +- [ ] **Step 3: Add password confirmation field** + +In `ui/src/pages/Admin/UsersTab.tsx`, find the create form (around line 238-252). Add a confirm password field: + +```typescript +const [newPasswordConfirm, setNewPasswordConfirm] = useState(''); +const passwordMismatch = newPassword.length > 0 && newPasswordConfirm.length > 0 && newPassword !== newPasswordConfirm; +``` + +After the existing password input: + +```tsx +{newProvider === 'local' && ( + <> + setNewPassword(e.target.value)} + /> + setNewPasswordConfirm(e.target.value)} + /> + {passwordMismatch && Passwords do not match} + Min 12 characters, 3 of 4: uppercase, lowercase, number, special + +)} +``` + +Add the `passwordMismatch` check to the Create button's `disabled` condition: + +```tsx +disabled={!newUsername.trim() || (newProvider === 'local' && (!newPassword.trim() || passwordMismatch)) || duplicateUsername} +``` + +Reset `newPasswordConfirm` when the form closes. + +Add `.hintText` to the CSS module: +```css +.hintText { + font-size: 12px; + color: var(--text-muted); +} +``` + +- [ ] **Step 4: Verify** + +1. Create user form: shows password confirmation field, validation message on mismatch, policy hint +2. API errors show descriptive messages in toasts +3. Role descriptions show rendered characters (middle dot), not escape sequences + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/utils/format-utils.ts ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Admin/RolesTab.tsx ui/src/pages/AppsTab/AppsTab.tsx ui/src/pages/Admin/AppConfigDetailPage.tsx +git commit -m "fix: surface API errors in toasts, fix unicode in roles, add password confirmation" +``` + +--- + +## Task 14: OIDC client secret masking and empty state standardization + +**Spec items:** 6.6, 2b.11 + +**Files:** +- Modify: `ui/src/pages/Admin/OidcConfigPage.tsx` +- Modify: `ui/src/pages/AppsTab/AppsTab.tsx` +- Modify: `ui/src/pages/Admin/UsersTab.tsx` +- Modify: `ui/src/pages/Admin/GroupsTab.tsx` +- Modify: `ui/src/pages/Routes/RouteDetail.tsx` + +- [ ] **Step 1: Mask OIDC client secret** + +In `ui/src/pages/Admin/OidcConfigPage.tsx`, find the Client Secret input field. Add a show/hide toggle: + +```typescript +const [showSecret, setShowSecret] = useState(false); +``` + +```tsx +
+ updateField('clientSecret', e.target.value)} + readOnly={!editing} + /> + +
+``` + +Import `Eye` and `EyeOff` from `lucide-react`. + +- [ ] **Step 2: Standardize empty states** + +Replace ad-hoc empty state patterns with the DS `EmptyState` component or a consistent pattern. Import from the design system: + +```typescript +import { EmptyState } from '@cameleer/design-system'; +``` + +In `AppsTab.tsx`, replace all `

...

` with: +```tsx + +``` + +In `GroupsTab.tsx`, replace `(no members)` with: +```tsx + +``` + +In `RouteDetail.tsx`, replace `
...
` and `
...
` with `EmptyState` component. + +Use the same pattern everywhere: `` + +- [ ] **Step 3: Verify** + +1. OIDC page: client secret is masked by default, eye icon toggles visibility +2. Empty states across all pages show consistent centered component + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/Admin/OidcConfigPage.tsx ui/src/pages/AppsTab/AppsTab.tsx ui/src/pages/Admin/UsersTab.tsx ui/src/pages/Admin/GroupsTab.tsx ui/src/pages/Routes/RouteDetail.tsx +git commit -m "fix: mask OIDC client secret, standardize empty states across pages" +``` + +--- + +## Task 15: Number formatting and locale consistency + +**Spec items:** 4.6 + +**Files:** +- Modify: `ui/src/utils/format-utils.ts` +- Modify: Files that display numbers with units + +- [ ] **Step 1: Add shared number formatting utility** + +In `ui/src/utils/format-utils.ts`, add: + +```typescript +export function formatMetric(value: number, unit: string, decimals = 1): string { + if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M ${unit}`; + if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(decimals)}K ${unit}`; + if (Number.isInteger(value)) return `${value} ${unit}`; + return `${value.toFixed(decimals)} ${unit}`; +} + +export function formatCount(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(value); +} + +export function formatPercent(value: number, decimals = 1): string { + return `${value.toFixed(decimals)} %`; +} +``` + +- [ ] **Step 2: Apply to key display locations** + +Search for locations where numbers are displayed with units and use the shared formatters. Focus on the KPI strips and dashboard metrics where inconsistencies were observed. + +```bash +grep -rn "msg/s\|/s\|ms\b" ui/src/pages/ --include="*.tsx" | head -20 +``` + +Update the most visible locations to use consistent formatting with space before unit. + +- [ ] **Step 3: Verify** + +1. KPI values show consistent formatting: "6.7 s", "1.9 %", "7.1 msg/s" +2. Large numbers use K/M suffixes consistently + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/utils/format-utils.ts ui/src/pages/ +git commit -m "fix: standardize number formatting with consistent unit spacing and K/M suffixes" +``` + +--- + +## Task 16: Platform label/value spacing and license badge colors (SaaS repo) + +**Spec items:** 6.4, 6.5 + +**Note:** These items are in the `cameleer-saas` repository, not `cameleer3-server`. If the SaaS platform UI code is in a separate repo, this task needs to be executed there. If it's co-located, proceed with these files. + +- [ ] **Step 1: Identify platform component files** + +```bash +# Check if SaaS platform UI is in this repo: +find . -name "*.tsx" | xargs grep -l "Slugdefault\|Max Agents" 2>/dev/null +# Or search for the platform dashboard component: +grep -rn "Tenant Information\|Server Management" ui/src/ --include="*.tsx" +``` + +If not found in this repo, note that these fixes belong to the `cameleer-saas` repo and skip to commit. + +- [ ] **Step 2: Fix label/value spacing** + +Find the key-value display components. Change from: +```tsx +Slug{tenant.slug} +``` +To: +```tsx +Slug: {tenant.slug} +``` + +Or better, use a definition list pattern: +```tsx +
+
Slug
{tenant.slug}
+
Status
{tenant.status}
+
Created
{formatDate(tenant.createdAt)}
+
+``` + +- [ ] **Step 3: Fix license badge colors** + +Find the license features display. Change DISABLED badges from error/red to neutral: + +```tsx +// BEFORE: +{feature.enabled ? 'ENABLED' : 'DISABLED'} + +// AFTER: +{feature.enabled ? 'ENABLED' : 'NOT INCLUDED'} +``` + +- [ ] **Step 4: Commit** + +```bash +# If in this repo: +git add +git commit -m "fix: platform label/value spacing and neutral license badge colors" +# If in cameleer-saas repo, note the fix for that repo +``` + +--- + +## Task 17: Audit log CSV export + +**Spec items:** 6.7 + +**Files:** +- Modify: `ui/src/pages/Admin/AuditLogPage.tsx` +- Modify: `cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AuditLogController.java` + +- [ ] **Step 1: Add client-side CSV export for current page** + +In `ui/src/pages/Admin/AuditLogPage.tsx`, add an export button to the table header area: + +```typescript +function exportCsv(events: AuditEvent[]) { + const headers = ['Timestamp', 'User', 'Category', 'Action', 'Target', 'Result', 'Details']; + const rows = events.map(e => [ + e.timestamp, e.username, e.category, e.action, e.target, e.result, e.details ?? '', + ]); + const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `cameleer-audit-${new Date().toISOString().slice(0, 16).replace(':', '-')}.csv`; + a.click(); + URL.revokeObjectURL(url); +} +``` + +Add the button in the table header alongside existing controls: + +```tsx + +``` + +Import `Download` from `lucide-react`. + +- [ ] **Step 2: Verify** + +1. Navigate to Admin > Audit Log +2. Click "Export CSV" — should download a CSV file with current page data +3. Open CSV and verify columns match the table + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/pages/Admin/AuditLogPage.tsx +git commit -m "feat: add CSV export to audit log" +``` + +--- + +## Task 18: Unsaved changes indicators (was Task 17) + +**Spec items:** 2b.12 + +**Files:** +- Modify: `ui/src/pages/Admin/AppConfigDetailPage.tsx` +- Modify: `ui/src/pages/Admin/EnvironmentsPage.tsx` + +- [ ] **Step 1: Add unsaved changes banner to AppConfigDetailPage** + +Borrow the banner pattern from AppsTab's ConfigSubTab. In `AppConfigDetailPage.tsx`, add a banner above the form when in edit mode: + +```tsx +{editing && ( +
+ Editing configuration. Changes are not saved until you click Save. +
+)} +``` + +Add the CSS: +```css +.editBanner { + padding: 8px 16px; + background: var(--amber-bg, rgba(198, 130, 14, 0.08)); + border: 1px solid var(--amber); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text-primary); + margin-bottom: 16px; +} +``` + +- [ ] **Step 2: Add unsaved changes banner to Environment editing sections** + +In `EnvironmentsPage.tsx`, find the Default Resources and JAR Retention edit sections. When `editing` is true, show the same banner pattern: + +```tsx +{editingResources && ( +
+ Editing resource defaults. Changes are not saved until you click Save. +
+)} +``` + +- [ ] **Step 3: Verify** + +1. AppConfigDetailPage: amber banner appears when in edit mode +2. Environments: amber banner appears when editing resource defaults or JAR retention + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/pages/Admin/AppConfigDetailPage.tsx ui/src/pages/Admin/EnvironmentsPage.tsx +git commit -m "fix: add unsaved changes banners to edit mode forms" +``` + +--- + +## Task 19: Nice-to-have polish (batch 7) + +**Spec items:** 7.1-7.10 (implement time-permitting) + +**Files:** +- Various + +- [ ] **Step 1: Deployment list status badges** + +In `AppsTab.tsx`, add a Status column to the app list DataTable showing the deployment status (RUNNING/STOPPED/FAILED) as a colored badge. This data may need to be fetched with the app list or joined from deployments. + +- [ ] **Step 2: Breadcrumb update on exchange selection** + +In `ExchangesPage.tsx`, when an exchange is selected, update the breadcrumb to show: All Applications > {appName} > Exchange ...{last8chars} + +- [ ] **Step 3: Close button on exchange detail panel** + +Add an X button or "Close" button to the top-right of the exchange detail panel for explicit dismissal. + +- [ ] **Step 4: Command palette exchange ID truncation** + +Find the command palette component and apply the same `...{last8chars}` truncation pattern used in the exchange table. + +- [ ] **Step 5: 7-Day Pattern heatmap insufficient data** + +In the DashboardTab heatmap component, check if data spans less than 2 days. If so, show an overlay or message: "More data needed — heatmap requires at least 2 days of history." + +- [ ] **Step 6: Commit all nice-to-have changes** + +```bash +git add -A ui/src/ +git commit -m "fix: nice-to-have polish — status badges, breadcrumbs, close button, heatmap message" +``` + +--- + +## Summary + +| Task | Batch | Key Changes | Commit | +|------|-------|-------------|--------| +| 1 | 1 (Bugs) | Deployments redirect, GC Pauses chart | `fix: add /deployments redirect and fix GC Pauses chart X-axis` | +| 2 | 1 (Bugs) | User creation OIDC fix (backend + frontend) | `fix: show descriptive error when creating local user with OIDC enabled` | +| 3 | 1 (Bugs) | SSE navigation bug investigation + fix | `fix: prevent SSE data updates from triggering navigation on admin pages` | +| 4 | 2a (Layout) | Exchange table containment, padding normalization | `fix: standardize table containment and container padding across pages` | +| 5 | 2a (Layout) | App detail section cards, deployment DataTable | `fix: wrap app config in section cards, replace manual table with DataTable` | +| 6 | 2a (Layout) | Deduplicate card CSS, wrap flat admin detail sections | `fix: deduplicate card CSS, use shared section-card and table-section modules` | +| 7 | 2b (Interaction) | Button order, confirmation dialogs, destructive guards | `fix: standardize button order, add confirmation dialogs for destructive actions` | +| 8 | 2b (Interaction) | OIDC edit mode, PageLoader, error toast format | `fix: add OIDC edit mode, standardize PageLoader and error toast format` | +| 9 | 3 (Contrast) | WCAG contrast fixes, font size floor | `fix: WCAG AA contrast compliance, 12px font floor` | +| 10 | 4 (Formatting) | Duration formatter, exchange ID truncation | `fix: improve duration formatting, truncate exchange IDs` | +| 11 | 4 (Formatting) | Attributes column, status labels, agent names | `fix: hide empty attributes, standardize status labels, truncate agent names` | +| 12 | 5 (Charts) | Y-axis scaling, error rate unit, memory ref line, pointer events | `fix: chart Y-axis auto-scaling, error rate unit, pointer events` | +| 13 | 6 (Admin) | API error details, unicode fix, password confirmation | `fix: surface API errors, fix unicode in roles, add password confirmation` | +| 14 | 6 (Admin) | OIDC secret masking, empty state standardization | `fix: mask OIDC client secret, standardize empty states` | +| 15 | 4 (Formatting) | Number formatting utility | `fix: standardize number formatting with unit spacing` | +| 16 | 6 (Admin) | Platform label/value spacing, license badges | `fix: platform label/value spacing and badge colors` | +| 17 | 6 (Admin) | Audit log CSV export | `feat: add CSV export to audit log` | +| 18 | 2b (Interaction) | Unsaved changes banners | `fix: add unsaved changes banners to edit mode forms` | +| 19 | 7 (Polish) | Nice-to-have items | `fix: nice-to-have polish` | From 2771dffb7898888637261c9ad6b62130669d28f2 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:16:53 +0200 Subject: [PATCH 03/18] fix: add /deployments redirect and fix GC Pauses chart X-axis Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/AgentInstance/AgentInstance.tsx | 2 +- ui/src/router.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx index a811776c..f1f6d831 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.tsx +++ b/ui/src/pages/AgentInstance/AgentInstance.tsx @@ -111,7 +111,7 @@ export default function AgentInstance() { const gcSeries = useMemo(() => { const pts = jvmMetrics?.metrics?.['jvm.gc.time']; if (!pts?.length) return null; - return [{ label: 'GC ms', data: pts.map((p: any) => ({ x: String(p.time ?? ''), y: p.value })) }]; + return [{ label: 'GC ms', data: pts.map((p: any, i: number) => ({ x: i, y: p.value })) }]; }, [jvmMetrics]); const throughputSeries = useMemo( diff --git a/ui/src/router.tsx b/ui/src/router.tsx index f4ec8b66..21dce608 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -65,6 +65,7 @@ export const router = createBrowserRouter([ { path: 'logs/:appId/:routeId', element: }, { path: 'config', element: }, { path: 'config/:appId', element: }, + { path: 'deployments', element: }, // Apps tab (OPERATOR+ via UI guard, shows all or single app) { path: 'apps', element: }, From be585934b9363f8e255bc77ae02b4522737ab2fd Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:19:10 +0200 Subject: [PATCH 04/18] fix: show descriptive error when creating local user with OIDC enabled Return a JSON error body from UserAdminController instead of an empty 400, and extract API error messages in adminFetch so toasts display the reason. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../server/app/controller/UserAdminController.java | 5 +++-- ui/src/api/queries/admin/admin-api.ts | 12 +++++++++++- ui/src/pages/Admin/UsersTab.tsx | 5 +++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java index b31f8089..4ff56d27 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java @@ -87,10 +87,11 @@ public class UserAdminController { @Operation(summary = "Create a local user") @ApiResponse(responseCode = "200", description = "User created") @ApiResponse(responseCode = "400", description = "Disabled in OIDC mode") - public ResponseEntity createUser(@RequestBody CreateUserRequest request, + public ResponseEntity createUser(@RequestBody CreateUserRequest request, HttpServletRequest httpRequest) { if (oidcEnabled) { - return ResponseEntity.badRequest().build(); + return ResponseEntity.badRequest() + .body(Map.of("error", "Local user creation is disabled when OIDC is enabled. Users are provisioned automatically via SSO.")); } String userId = "user:" + request.username(); UserInfo user = new UserInfo(userId, "local", diff --git a/ui/src/api/queries/admin/admin-api.ts b/ui/src/api/queries/admin/admin-api.ts index be6cfb24..a92516b7 100644 --- a/ui/src/api/queries/admin/admin-api.ts +++ b/ui/src/api/queries/admin/admin-api.ts @@ -16,7 +16,17 @@ export async function adminFetch(path: string, options?: RequestInit): Promis useAuthStore.getState().logout(); throw new Error('Unauthorized'); } - if (!res.ok) throw new Error(`API error: ${res.status}`); + if (!res.ok) { + let message = `API error: ${res.status}`; + try { + const body = await res.json(); + if (body?.error) message = body.error; + else if (body?.message) message = body.message; + } catch { + // no JSON body — keep generic message + } + throw new Error(message); + } if (res.status === 204) return undefined as T; const text = await res.text(); if (!text) return undefined as T; diff --git a/ui/src/pages/Admin/UsersTab.tsx b/ui/src/pages/Admin/UsersTab.tsx index 4d3bafbe..7e7ec05f 100644 --- a/ui/src/pages/Admin/UsersTab.tsx +++ b/ui/src/pages/Admin/UsersTab.tsx @@ -145,8 +145,9 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig setNewPassword(''); setNewProvider('local'); }, - onError: () => { - toast({ title: 'Failed to create user', variant: 'error', duration: 86_400_000 }); + onError: (err: unknown) => { + const message = err instanceof Error ? err.message : 'Unknown error'; + toast({ title: 'Failed to create user', description: message, variant: 'error', duration: 86_400_000 }); }, }, ); From ba53f91f4a5a40dd0e8cab57619ab80e7e3393c8 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:21:58 +0200 Subject: [PATCH 05/18] fix: standardize table containment and container padding across pages Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/AppsTab/AppsTab.module.css | 2 +- ui/src/pages/Dashboard/Dashboard.module.css | 29 ------- ui/src/pages/Dashboard/Dashboard.tsx | 87 ++++++++++--------- .../DashboardTab/DashboardTab.module.css | 2 +- 4 files changed, 47 insertions(+), 73 deletions(-) diff --git a/ui/src/pages/AppsTab/AppsTab.module.css b/ui/src/pages/AppsTab/AppsTab.module.css index 3ecf5b8f..8e9aa076 100644 --- a/ui/src/pages/AppsTab/AppsTab.module.css +++ b/ui/src/pages/AppsTab/AppsTab.module.css @@ -1,5 +1,5 @@ .container { - padding: 16px; + padding: 20px 24px 40px; overflow-y: auto; flex: 1; } diff --git a/ui/src/pages/Dashboard/Dashboard.module.css b/ui/src/pages/Dashboard/Dashboard.module.css index dbfa5f54..a0e88bc4 100644 --- a/ui/src/pages/Dashboard/Dashboard.module.css +++ b/ui/src/pages/Dashboard/Dashboard.module.css @@ -9,23 +9,6 @@ background: var(--bg-body); } -.tableHeader { - display: flex; - align-items: center; - justify-content: space-between; - padding: 8px 12px; - border-bottom: 1px solid var(--border-subtle); -} - -.tableTitle { - display: flex; - align-items: center; - gap: 4px; - font-size: 13px; - font-weight: 600; - color: var(--text-primary); -} - .clearSearch { display: inline-flex; align-items: center; @@ -46,18 +29,6 @@ color: var(--text-primary); } -.tableRight { - display: flex; - align-items: center; - gap: 10px; -} - -.tableMeta { - font-size: 12px; - color: var(--text-muted); - font-family: var(--font-mono); -} - /* Status cell */ .statusCell { display: flex; diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx index b1b1cf57..112cae58 100644 --- a/ui/src/pages/Dashboard/Dashboard.tsx +++ b/ui/src/pages/Dashboard/Dashboard.tsx @@ -17,6 +17,7 @@ import type { ExecutionSummary } from '../../api/types' import { attributeBadgeColor } from '../../utils/attribute-color' import { formatDuration, statusLabel } from '../../utils/format-utils' import styles from './Dashboard.module.css' +import tableStyles from '../../styles/table-section.module.css' // Row type extends ExecutionSummary with an `id` field for DataTable interface Row extends ExecutionSummary { @@ -234,52 +235,54 @@ export default function Dashboard({ onExchangeSelect, activeExchangeId }: Dashbo return (
-
- - {textFilter ? ( - <> - - Search: “{textFilter}” - - - ) : 'Recent Exchanges'} - -
- - {rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges +
+
+ + {textFilter ? ( + <> + + Search: “{textFilter}” + + + ) : 'Recent Exchanges'} - {!textFilter && } +
+ + {rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges + + {!textFilter && } +
-
- - row.errorMessage ? ( -
- -
-
{row.errorMessage}
-
Click to view full stack trace
+ + row.errorMessage ? ( +
+ +
+
{row.errorMessage}
+
Click to view full stack trace
+
-
- ) : null - } - /> + ) : null + } + /> +
) } diff --git a/ui/src/pages/DashboardTab/DashboardTab.module.css b/ui/src/pages/DashboardTab/DashboardTab.module.css index c6304a61..f31f282d 100644 --- a/ui/src/pages/DashboardTab/DashboardTab.module.css +++ b/ui/src/pages/DashboardTab/DashboardTab.module.css @@ -5,7 +5,7 @@ flex: 1; min-height: 0; overflow-y: auto; - padding-bottom: 20px; + padding: 0 24px 20px; } From 3f9fd44ea5e3a1aa5b28a05667a0c902edd3d66b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:28:11 +0200 Subject: [PATCH 06/18] fix: wrap app config in section cards, replace manual table with DataTable - Add sectionStyles and tableStyles imports to AppsTab.tsx - Wrap CreateAppView identity section and each config tab (Monitoring, Resources, Variables) in sectionStyles.section cards - Wrap ConfigSubTab config tabs (Monitoring, Resources, Variables, Traces & Taps, Route Recording) in sectionStyles.section cards - Replace manual
in OverviewSubTab with DataTable inside a tableStyles.tableSection card wrapper; pre-compute enriched row data via useMemo; handle muted non-selected-env rows via inline opacity - Remove unused .table, .table th, .table td, .table tr:hover td, and .mutedRow CSS rules from AppsTab.module.css Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/AppsTab/AppsTab.module.css | 32 -- ui/src/pages/AppsTab/AppsTab.tsx | 399 +++++++++++++----------- 2 files changed, 213 insertions(+), 218 deletions(-) diff --git a/ui/src/pages/AppsTab/AppsTab.module.css b/ui/src/pages/AppsTab/AppsTab.module.css index 8e9aa076..3f943187 100644 --- a/ui/src/pages/AppsTab/AppsTab.module.css +++ b/ui/src/pages/AppsTab/AppsTab.module.css @@ -119,38 +119,6 @@ gap: 8px; } -/* Table */ -.table { - width: 100%; - border-collapse: collapse; - margin-bottom: 16px; -} - -.table th { - text-align: left; - padding: 8px 12px; - font-size: 11px; - font-weight: 600; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.5px; - border-bottom: 1px solid var(--border-subtle); -} - -.table td { - padding: 10px 12px; - border-bottom: 1px solid var(--border-subtle); - font-size: 13px; - vertical-align: middle; -} - -.table tr:hover td { - background: var(--bg-hover); -} - -.mutedRow td { - opacity: 0.45; -} .mutedMono { font-family: var(--font-mono); diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index f89f73f2..9140af35 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -40,6 +40,8 @@ import { DeploymentProgress } from '../../components/DeploymentProgress'; import { timeAgo } from '../../utils/format-utils'; import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils'; import styles from './AppsTab.module.css'; +import sectionStyles from '../../styles/section-card.module.css'; +import tableStyles from '../../styles/table-section.module.css'; function formatBytes(bytes: number): string { if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; @@ -290,35 +292,37 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen {step &&
{step}
} {/* Identity Section */} - Identity & Artifact -
- Application Name - setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} /> +
+ Identity & Artifact +
+ Application Name + setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} /> - External URL - /{env?.slug ?? '...'}/{slug || '...'}/ + External URL + /{env?.slug ?? '...'}/{slug || '...'}/ - Environment - setEnvId(e.target.value)} disabled={busy} + options={environments.filter((e) => e.enabled).map((e) => ({ value: e.id, label: `${e.displayName} (${e.slug})` }))} /> - Application JAR -
- setFile(e.target.files?.[0] ?? null)} /> - - {file && {file.name} ({formatBytes(file.size)})} -
+ Application JAR +
+ setFile(e.target.files?.[0] ?? null)} /> + + {file && {file.name} ({formatBytes(file.size)})} +
- Deploy -
- setDeploy(!deploy)} disabled={busy} /> - - {deploy ? 'Deploy immediately after creation' : 'Create only (deploy later)'} - + Deploy +
+ setDeploy(!deploy)} disabled={busy} /> + + {deploy ? 'Deploy immediately after creation' : 'Create only (deploy later)'} + +
@@ -334,7 +338,8 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen /> {configTab === 'variables' && ( - <> +
+ Variables {envVars.map((v, i) => (
{ @@ -348,68 +353,71 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
))} - +
)} {configTab === 'monitoring' && ( -
- Engine Level - setEngineLevel(e.target.value)} + options={[{ value: 'NONE', label: 'NONE' }, { value: 'MINIMAL', label: 'MINIMAL' }, { value: 'REGULAR', label: 'REGULAR' }, { value: 'COMPLETE', label: 'COMPLETE' }]} /> - Payload Capture - setPayloadCapture(e.target.value)} + options={[{ value: 'NONE', label: 'NONE' }, { value: 'INPUT', label: 'INPUT' }, { value: 'OUTPUT', label: 'OUTPUT' }, { value: 'BOTH', label: 'BOTH' }]} /> - Max Payload Size -
- setPayloadSize(e.target.value)} className={styles.inputMd} /> - setPayloadSize(e.target.value)} className={styles.inputMd} /> + setAppLogLevel(e.target.value)} - options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} /> + App Log Level + setAgentLogLevel(e.target.value)} - options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} /> + Agent Log Level + setMetricsInterval(e.target.value)} className={styles.inputXs} /> - s -
+ Metrics +
+ !busy && setMetricsEnabled(!metricsEnabled)} disabled={busy} /> + {metricsEnabled ? 'Enabled' : 'Disabled'} + Interval + setMetricsInterval(e.target.value)} className={styles.inputXs} /> + s +
- Sampling Rate - setSamplingRate(e.target.value)} className={styles.inputLg} /> + Sampling Rate + setSamplingRate(e.target.value)} className={styles.inputLg} /> - Compress Success -
- !busy && setCompressSuccess(!compressSuccess)} disabled={busy} /> - {compressSuccess ? 'Enabled' : 'Disabled'} -
+ Compress Success +
+ !busy && setCompressSuccess(!compressSuccess)} disabled={busy} /> + {compressSuccess ? 'Enabled' : 'Disabled'} +
- Replay -
- !busy && setReplayEnabled(!replayEnabled)} disabled={busy} /> - {replayEnabled ? 'Enabled' : 'Disabled'} -
+ Replay +
+ !busy && setReplayEnabled(!replayEnabled)} disabled={busy} /> + {replayEnabled ? 'Enabled' : 'Disabled'} +
- Route Control -
- !busy && setRouteControlEnabled(!routeControlEnabled)} disabled={busy} /> - {routeControlEnabled ? 'Enabled' : 'Disabled'} + Route Control +
+ !busy && setRouteControlEnabled(!routeControlEnabled)} disabled={busy} /> + {routeControlEnabled ? 'Enabled' : 'Disabled'} +
)} {configTab === 'resources' && ( - <> +
Container Resources
Memory Limit @@ -472,7 +480,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen {sslOffloading ? 'Enabled' : 'Disabled'}
- +
)}
); @@ -615,70 +623,84 @@ function OverviewSubTab({ app, deployments, versions, environments, envMap, sele [environments, selectedEnv], ); + type DeploymentRow = Deployment & { + dEnv: Environment | undefined; + version: AppVersion | undefined; + isSelectedEnv: boolean; + canAct: boolean; + canStart: boolean; + configChanged: boolean; + url: string; + }; + + const deploymentRows: DeploymentRow[] = useMemo(() => deployments.map((d) => { + const dEnv = envMap.get(d.environmentId); + const version = versions.find((v) => v.id === d.appVersionId); + const isSelectedEnv = !selectedEnvId || d.environmentId === selectedEnvId; + const canAct = isSelectedEnv && (d.status === 'RUNNING' || d.status === 'STARTING'); + const canStart = isSelectedEnv && d.status === 'STOPPED'; + const configChanged = canAct && !!d.deployedAt && new Date(app.updatedAt) > new Date(d.deployedAt); + const url = dEnv ? `/${dEnv.slug}/${app.slug}/` : ''; + return { ...d, dEnv, version, isSelectedEnv, canAct, canStart, configChanged, url }; + }), [deployments, envMap, versions, selectedEnvId, app]); + + const deploymentColumns: Column[] = useMemo(() => [ + { key: 'environmentId', header: 'Environment', render: (_v, row) => ( + + + + )}, + { key: 'appVersionId', header: 'Version', render: (_v, row) => ( + + + + )}, + { key: 'status', header: 'Status', render: (_v, row) => ( +
+ + +
+ )}, + { key: 'replicaStates', header: 'Replicas', render: (_v, row) => ( + + {row.replicaStates && row.replicaStates.length > 0 + ? {row.replicaStates.filter((r) => r.status === 'RUNNING').length}/{row.replicaStates.length} + : <>{'—'}} + + )}, + { key: 'url' as any, header: 'URL', render: (_v, row) => ( + + {row.status === 'RUNNING' + ? {row.url} + : {row.url}} + + )}, + { key: 'deployedAt', header: 'Deployed', render: (_v, row) => ( + + {row.deployedAt ? timeAgo(row.deployedAt) : '—'} + + )}, + { key: 'actions' as any, header: '', render: (_v, row) => ( +
+ {row.configChanged && } + {row.canAct && } + {row.canStart && } + {!row.isSelectedEnv && switch env to manage} +
+ )}, + ], [onDeploy, onStop]); + return ( <> - Deployments - {deployments.length === 0 &&

No deployments yet.

} - {deployments.length > 0 && ( -
- - - - - - - - - - - - - {deployments.map((d) => { - const dEnv = envMap.get(d.environmentId); - const version = versions.find((v) => v.id === d.appVersionId); - const isSelectedEnv = !selectedEnvId || d.environmentId === selectedEnvId; - const canAct = isSelectedEnv && (d.status === 'RUNNING' || d.status === 'STARTING'); - const canStart = isSelectedEnv && d.status === 'STOPPED'; - const configChanged = canAct && d.deployedAt && new Date(app.updatedAt) > new Date(d.deployedAt); - const url = dEnv ? `/${dEnv.slug}/${app.slug}/` : ''; - - return ( - - - - - - - - - - ); - })} - -
EnvironmentVersionStatusReplicasURLDeployedActions
- - - - - - {d.replicaStates && d.replicaStates.length > 0 ? ( - - {d.replicaStates.filter((r) => r.status === 'RUNNING').length}/{d.replicaStates.length} - - ) : '—'} - - {d.status === 'RUNNING' ? ( - {url} - ) : ( - {url} - )} - {d.deployedAt ? timeAgo(d.deployedAt) : '—'} - {configChanged && } - {canAct && } - {canStart && } - {!isSelectedEnv && switch env to manage} -
- )} +
+
+ Deployments +
+ {deploymentRows.length === 0 + ?

No deployments yet.

+ : columns={deploymentColumns} data={deploymentRows} flush /> + } +
{deployments.filter((d) => d.deployStage).map((d) => (
{d.containerName} @@ -949,7 +971,8 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen /> {configTab === 'variables' && ( - <> +
+ Variables {envVars.map((v, i) => (
{ @@ -966,87 +989,91 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen )} {envVars.length === 0 && !editing &&

No environment variables configured.

} - +
)} {configTab === 'monitoring' && ( -
- Engine Level - setEngineLevel(e.target.value)} + options={[{ value: 'NONE', label: 'NONE' }, { value: 'MINIMAL', label: 'MINIMAL' }, { value: 'REGULAR', label: 'REGULAR' }, { value: 'COMPLETE', label: 'COMPLETE' }]} /> - Payload Capture - setPayloadCapture(e.target.value)} + options={[{ value: 'NONE', label: 'NONE' }, { value: 'INPUT', label: 'INPUT' }, { value: 'OUTPUT', label: 'OUTPUT' }, { value: 'BOTH', label: 'BOTH' }]} /> - Max Payload Size -
- setPayloadSize(e.target.value)} className={styles.inputMd} /> - setPayloadSize(e.target.value)} className={styles.inputMd} /> + setAppLogLevel(e.target.value)} - options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} /> + App Log Level + setAgentLogLevel(e.target.value)} - options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} /> + Agent Log Level + setMetricsInterval(e.target.value)} className={styles.inputXs} /> - s -
+ Metrics +
+ editing && setMetricsEnabled(!metricsEnabled)} disabled={!editing} /> + {metricsEnabled ? 'Enabled' : 'Disabled'} + Interval + setMetricsInterval(e.target.value)} className={styles.inputXs} /> + s +
- Sampling Rate - setSamplingRate(e.target.value)} className={styles.inputLg} /> + Sampling Rate + setSamplingRate(e.target.value)} className={styles.inputLg} /> - Compress Success -
- editing && setCompressSuccess(!compressSuccess)} disabled={!editing} /> - {compressSuccess ? 'Enabled' : 'Disabled'} -
+ Compress Success +
+ editing && setCompressSuccess(!compressSuccess)} disabled={!editing} /> + {compressSuccess ? 'Enabled' : 'Disabled'} +
- Replay -
- editing && setReplayEnabled(!replayEnabled)} disabled={!editing} /> - {replayEnabled ? 'Enabled' : 'Disabled'} -
+ Replay +
+ editing && setReplayEnabled(!replayEnabled)} disabled={!editing} /> + {replayEnabled ? 'Enabled' : 'Disabled'} +
- Route Control -
- editing && setRouteControlEnabled(!routeControlEnabled)} disabled={!editing} /> - {routeControlEnabled ? 'Enabled' : 'Disabled'} + Route Control +
+ editing && setRouteControlEnabled(!routeControlEnabled)} disabled={!editing} /> + {routeControlEnabled ? 'Enabled' : 'Disabled'} +
)} {configTab === 'traces' && ( - <> +
+ Traces & Taps {tracedCount} traced · {tapCount} taps {tracedTapRows.length > 0 ? columns={tracedTapColumns} data={tracedTapRows} pageSize={20} flush /> :

No processor traces or taps configured.

} - +
)} {configTab === 'recording' && ( - <> +
+ Route Recording {recordingCount} of {routeRecordingRows.length} routes recording {routeRecordingRows.length > 0 ? columns={routeRecordingColumns} data={routeRecordingRows} pageSize={20} flush /> :

No routes found for this application.

} - +
)} {configTab === 'resources' && ( - <> - {/* Container Resources */} +
Container Resources
Memory Limit @@ -1109,7 +1136,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen {sslOffloading ? 'Enabled' : 'Disabled'}
- +
)} ); From b6b93dc3ccba2b70e3e9ec831e95a094cbea9d8c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:28:45 +0200 Subject: [PATCH 07/18] fix: prevent admin page redirect during token refresh adminFetch called logout() directly on 401/403 responses, which cleared roles and caused RequireAdmin to redirect to /exchanges while users were editing forms. Now adminFetch attempts a token refresh before failing, and RequireAdmin tolerates a transient empty-roles state during refresh. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/api/queries/admin/admin-api.ts | 37 +++++++++++++++++++++++++-- ui/src/auth/RequireAdmin.tsx | 18 ++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/ui/src/api/queries/admin/admin-api.ts b/ui/src/api/queries/admin/admin-api.ts index a92516b7..cea51560 100644 --- a/ui/src/api/queries/admin/admin-api.ts +++ b/ui/src/api/queries/admin/admin-api.ts @@ -1,9 +1,40 @@ import { config } from '../../../config'; import { useAuthStore } from '../../../auth/auth-store'; +/** + * Shared fetch helper for admin API endpoints. + * + * On a 401 response the helper attempts a single token refresh and retries + * the original request. Only if the retry also fails (or there is no refresh + * token) does it fall through to the normal error path. + * + * Previously this function called `logout()` directly on 401/403 which could + * race with background polling: when an access-token expired while the user + * was editing a form, the immediate logout cleared `roles`, causing + * `RequireAdmin` to redirect to `/` -> `/exchanges` and losing unsaved state. + */ export async function adminFetch(path: string, options?: RequestInit): Promise { + const res = await _doFetch(path, options); + + // 401 — attempt a token refresh and retry once + if (res.status === 401) { + const refreshed = await useAuthStore.getState().refresh(); + if (refreshed) { + const retry = await _doFetch(path, options); + return _handleResponse(retry); + } + // Refresh failed — throw without calling logout(). + // The central onUnauthorized handler (use-auth.ts) will take care of + // redirecting to /login if appropriate. + throw new Error('Unauthorized'); + } + + return _handleResponse(res); +} + +async function _doFetch(path: string, options?: RequestInit): Promise { const token = useAuthStore.getState().accessToken; - const res = await fetch(`${config.apiBaseUrl}/admin${path}`, { + return fetch(`${config.apiBaseUrl}/admin${path}`, { ...options, headers: { 'Content-Type': 'application/json', @@ -12,8 +43,10 @@ export async function adminFetch(path: string, options?: RequestInit): Promis ...options?.headers, }, }); +} + +async function _handleResponse(res: Response): Promise { if (res.status === 401 || res.status === 403) { - useAuthStore.getState().logout(); throw new Error('Unauthorized'); } if (!res.ok) { diff --git a/ui/src/auth/RequireAdmin.tsx b/ui/src/auth/RequireAdmin.tsx index ca31947e..124be772 100644 --- a/ui/src/auth/RequireAdmin.tsx +++ b/ui/src/auth/RequireAdmin.tsx @@ -1,8 +1,24 @@ import { Navigate, Outlet } from 'react-router'; -import { useIsAdmin } from './auth-store'; +import { useAuthStore, useIsAdmin } from './auth-store'; +/** + * Route guard for admin pages. + * + * Redirects non-admin users to '/'. The guard is intentionally lenient when + * the user is authenticated but their roles array is transiently empty (e.g. + * during a token refresh). In that case we render nothing rather than + * navigating away — the refresh will restore the roles within milliseconds and + * avoid losing unsaved form state on admin pages. + */ export function RequireAdmin() { const isAdmin = useIsAdmin(); + const roles = useAuthStore((s) => s.roles); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + // Authenticated but roles not yet populated (token refresh in progress) — + // render nothing and wait rather than redirecting. + if (isAuthenticated && roles.length === 0) return null; + if (!isAdmin) return ; return ; } From ba0a1850a9badf3380d5c3b3d6039135ea218026 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:31:51 +0200 Subject: [PATCH 08/18] fix: WCAG AA contrast compliance for --text-muted/--text-faint, 12px font floor Override design system tokens in app root CSS: --text-muted raised to 4.5:1 contrast in both light (#766A5E) and dark (#9A9088) modes; --text-faint dark mode raised from catastrophic 1.4:1 to 3:1 (#6A6058). Migrate --text-faint usages on readable text (empty states, italic notes, buttons) to --text-muted. Raise all 10px and 11px font-size declarations to 12px floor. Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/AboutMeDialog.module.css | 2 +- ui/src/components/DeploymentProgress.module.css | 2 +- ui/src/index.css | 13 +++++++++++++ ui/src/pages/Admin/OidcConfigPage.module.css | 2 +- ui/src/pages/AgentHealth/AgentHealth.module.css | 14 +++----------- ui/src/pages/AppsTab/AppsTab.module.css | 6 +++--- ui/src/styles/log-panel.module.css | 2 +- 7 files changed, 23 insertions(+), 18 deletions(-) diff --git a/ui/src/components/AboutMeDialog.module.css b/ui/src/components/AboutMeDialog.module.css index b46f5853..f9948d51 100644 --- a/ui/src/components/AboutMeDialog.module.css +++ b/ui/src/components/AboutMeDialog.module.css @@ -44,7 +44,7 @@ } .sourceNote { - font-size: 11px; + font-size: 12px; color: var(--text-muted); font-family: var(--font-mono); } diff --git a/ui/src/components/DeploymentProgress.module.css b/ui/src/components/DeploymentProgress.module.css index 3e40b4f7..25dba66b 100644 --- a/ui/src/components/DeploymentProgress.module.css +++ b/ui/src/components/DeploymentProgress.module.css @@ -74,7 +74,7 @@ /* Labels */ .label { - font-size: 10px; + font-size: 12px; color: var(--text-muted); text-align: center; margin-top: 6px; diff --git a/ui/src/index.css b/ui/src/index.css index 83ff9288..f55a911d 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -58,6 +58,19 @@ src: url('./fonts/jetbrains-mono-600.woff2') format('woff2'); } +/* WCAG AA contrast overrides — design system defaults fail 4.5:1 minimum */ +:root { + /* Light mode: #766A5E on #FFFFFF = 4.5:1 (was #9C9184 = 3.0:1) */ + --text-muted: #766A5E; +} + +[data-theme="dark"] { + /* Dark mode: #9A9088 on #242019 = 4.5:1 (was #7A7068 = 2.9:1) */ + --text-muted: #9A9088; + /* Dark mode: #6A6058 on #242019 = 3:1 (was #4A4238 = 1.4:1 — catastrophic) */ + --text-faint: #6A6058; +} + :root { font-family: 'DM Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; diff --git a/ui/src/pages/Admin/OidcConfigPage.module.css b/ui/src/pages/Admin/OidcConfigPage.module.css index 868433c5..21fb6ae9 100644 --- a/ui/src/pages/Admin/OidcConfigPage.module.css +++ b/ui/src/pages/Admin/OidcConfigPage.module.css @@ -30,7 +30,7 @@ .noRoles { font-size: 12px; - color: var(--text-faint); + color: var(--text-muted); font-style: italic; font-family: var(--font-body); } diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index 470f32a4..32f7e695 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -31,17 +31,13 @@ .routesWarning { color: var(--warning); } .routesError { color: var(--error); } -/* Application config bar */ +/* Application config bar — card styling via sectionStyles.section */ .configBar { display: flex; align-items: flex-end; gap: 20px; padding: 12px 16px; margin-bottom: 16px; - background: var(--bg-surface); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); } .configField { @@ -133,7 +129,7 @@ .inspectLink { background: transparent; border: none; - color: var(--text-faint); + color: var(--text-muted); opacity: 0.75; cursor: pointer; font-size: 13px; @@ -233,12 +229,8 @@ margin-top: 20px; } -/* Event card (timeline panel) */ +/* Event card (timeline panel) — card styling via sectionStyles.section */ .eventCard { - background: var(--bg-surface); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); overflow: hidden; display: flex; flex-direction: column; diff --git a/ui/src/pages/AppsTab/AppsTab.module.css b/ui/src/pages/AppsTab/AppsTab.module.css index 3f943187..a5bb5339 100644 --- a/ui/src/pages/AppsTab/AppsTab.module.css +++ b/ui/src/pages/AppsTab/AppsTab.module.css @@ -122,13 +122,13 @@ .mutedMono { font-family: var(--font-mono); - font-size: 11px; + font-size: 12px; color: var(--text-muted); opacity: 0.5; } .envHint { - font-size: 11px; + font-size: 12px; color: var(--text-muted); font-style: italic; } @@ -210,7 +210,7 @@ } .configHint { - font-size: 11px; + font-size: 12px; color: var(--text-muted); font-style: italic; margin-top: 2px; diff --git a/ui/src/styles/log-panel.module.css b/ui/src/styles/log-panel.module.css index 143d84da..4bc2b69f 100644 --- a/ui/src/styles/log-panel.module.css +++ b/ui/src/styles/log-panel.module.css @@ -83,7 +83,7 @@ .logEmpty { padding: 24px; text-align: center; - color: var(--text-faint); + color: var(--text-muted); font-size: 12px; } From eadcd160a3cb905cbf99037e94f87097c737ea51 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:34:04 +0200 Subject: [PATCH 09/18] 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 --- .../Admin/ClickHouseAdminPage.module.css | 6 +- ui/src/pages/Admin/ClickHouseAdminPage.tsx | 3 +- ui/src/pages/Admin/DatabaseAdminPage.tsx | 13 +- ui/src/pages/Admin/EnvironmentsPage.tsx | 57 ++-- ui/src/pages/Admin/GroupsTab.tsx | 119 ++++---- ui/src/pages/Admin/UsersTab.tsx | 287 +++++++++--------- ui/src/pages/AgentHealth/AgentHealth.tsx | 5 +- .../AgentInstance/AgentInstance.module.css | 12 +- ui/src/pages/AgentInstance/AgentInstance.tsx | 5 +- ui/src/pages/Dashboard/Dashboard.tsx | 14 +- ui/src/pages/DashboardTab/DashboardL2.tsx | 2 +- ui/src/pages/DashboardTab/DashboardL3.tsx | 4 +- .../DashboardTab/DashboardTab.module.css | 18 +- ui/src/utils/format-utils.ts | 17 +- 14 files changed, 293 insertions(+), 269 deletions(-) diff --git a/ui/src/pages/Admin/ClickHouseAdminPage.module.css b/ui/src/pages/Admin/ClickHouseAdminPage.module.css index 6123c7d3..6c636748 100644 --- a/ui/src/pages/Admin/ClickHouseAdminPage.module.css +++ b/ui/src/pages/Admin/ClickHouseAdminPage.module.css @@ -5,12 +5,8 @@ flex-wrap: wrap; } +/* pipelineCard — card styling via sectionStyles.section */ .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; } diff --git a/ui/src/pages/Admin/ClickHouseAdminPage.tsx b/ui/src/pages/Admin/ClickHouseAdminPage.tsx index 34e22dd0..f1fd1ade 100644 --- a/ui/src/pages/Admin/ClickHouseAdminPage.tsx +++ b/ui/src/pages/Admin/ClickHouseAdminPage.tsx @@ -2,6 +2,7 @@ import { StatCard, DataTable, ProgressBar } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; import { useClickHouseStatus, useClickHouseTables, useClickHousePerformance, useClickHouseQueries, useIndexerPipeline } from '../../api/queries/admin/clickhouse'; import styles from './ClickHouseAdminPage.module.css'; +import sectionStyles from '../../styles/section-card.module.css'; import tableStyles from '../../styles/table-section.module.css'; export default function ClickHouseAdminPage() { @@ -53,7 +54,7 @@ export default function ClickHouseAdminPage() { {/* Pipeline */} {pipeline && ( -
+
Indexer Pipeline
0 ? (pipeline.queueDepth / pipeline.maxQueueSize) * 100 : 0} />
diff --git a/ui/src/pages/Admin/DatabaseAdminPage.tsx b/ui/src/pages/Admin/DatabaseAdminPage.tsx index 5a02693e..2448b9fc 100644 --- a/ui/src/pages/Admin/DatabaseAdminPage.tsx +++ b/ui/src/pages/Admin/DatabaseAdminPage.tsx @@ -2,6 +2,7 @@ import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner } from ' import type { Column } from '@cameleer/design-system'; import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database'; import styles from './DatabaseAdminPage.module.css'; +import tableStyles from '../../styles/table-section.module.css'; export default function DatabaseAdminPage() { const { data: status, isError: statusError } = useDatabaseStatus(); @@ -54,13 +55,17 @@ export default function DatabaseAdminPage() { )} -
-
Tables
+
+
+ Tables +
({ ...t, id: t.tableName }))} sortable pageSize={20} />
-
-
Active Queries
+
+
+ Active Queries +
({ ...q, id: String(q.pid) }))} />
diff --git a/ui/src/pages/Admin/EnvironmentsPage.tsx b/ui/src/pages/Admin/EnvironmentsPage.tsx index 58aa0974..f3cb475d 100644 --- a/ui/src/pages/Admin/EnvironmentsPage.tsx +++ b/ui/src/pages/Admin/EnvironmentsPage.tsx @@ -25,6 +25,7 @@ import { } from '../../api/queries/admin/environments'; import type { Environment } from '../../api/queries/admin/environments'; import styles from './UserManagement.module.css'; +import sectionStyles from '../../styles/section-card.module.css'; function slugify(name: string): string { return name @@ -263,34 +264,38 @@ export default function EnvironmentsPage() {
- Configuration -
-
- handleToggleProduction(!selected.production)} /> - Production environment - {selected.production ? ( - - ) : ( - - )} +
+ Configuration +
+
+ handleToggleProduction(!selected.production)} /> + Production environment + {selected.production ? ( + + ) : ( + + )} +
- Status -
-
- handleToggleEnabled(!selected.enabled)} /> - {selected.enabled ? 'Enabled' : 'Disabled'} +
+ Status +
+
+ handleToggleEnabled(!selected.enabled)} /> + {selected.enabled ? 'Enabled' : 'Disabled'} + {!selected.enabled && ( + + )} +
{!selected.enabled && ( - +

+ Disabled environments do not allow new deployments. Active + deployments can only be started, stopped, or deleted. +

)}
- {!selected.enabled && ( -

- Disabled environments do not allow new deployments. Active - deployments can only be started, stopped, or deleted. -

- )}
{ @@ -385,7 +390,7 @@ function DefaultResourcesSection({ environment, onSave, saving }: { } return ( - <> +
Default Resource Limits

These defaults apply to new apps in this environment unless overridden per-app. @@ -444,7 +449,7 @@ function DefaultResourcesSection({ environment, onSave, saving }: { )}

- +
); } @@ -478,7 +483,7 @@ function JarRetentionSection({ environment, onSave, saving }: { } return ( - <> +
JAR Retention

Old JAR versions are cleaned up nightly. Currently deployed versions are never deleted. @@ -512,6 +517,6 @@ function JarRetentionSection({ environment, onSave, saving }: { )}

- +
); } diff --git a/ui/src/pages/Admin/GroupsTab.tsx b/ui/src/pages/Admin/GroupsTab.tsx index b826d997..e6a8967d 100644 --- a/ui/src/pages/Admin/GroupsTab.tsx +++ b/ui/src/pages/Admin/GroupsTab.tsx @@ -32,6 +32,7 @@ import { } from '../../api/queries/admin/rbac'; import type { GroupDetail } from '../../api/queries/admin/rbac'; import styles from './UserManagement.module.css'; +import sectionStyles from '../../styles/section-card.module.css'; const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010'; @@ -361,69 +362,75 @@ export default function GroupsTab({ highlightId, onHighlightConsumed }: { highli )} - Members (direct) -
- {members.map((u) => ( - handleRemoveMember(u.userId)} +
+ Members (direct) +
+ {members.map((u) => ( + handleRemoveMember(u.userId)} + /> + ))} + {members.length === 0 && ( + (no members) + )} + - ))} - {members.length === 0 && ( - (no members) - )} - -
- {children.length > 0 && ( - - + all members of {children.map((c) => c.name).join(', ')} - - )} - - Child groups -
- {children.map((c) => ( - - ))} - {children.length === 0 && ( +
+ {children.length > 0 && ( - (no child groups) + + all members of {children.map((c) => c.name).join(', ')} )}
- Assigned roles -
- {(selectedGroup.directRoles ?? []).map((r) => ( - { - if (members.length > 0) { - setRemoveRoleTarget(r.id); - } else { - handleRemoveRole(r.id); - } - }} +
+ Child groups +
+ {children.map((c) => ( + + ))} + {children.length === 0 && ( + + (no child groups) + + )} +
+
+ +
+ Assigned roles +
+ {(selectedGroup.directRoles ?? []).map((r) => ( + { + if (members.length > 0) { + setRemoveRoleTarget(r.id); + } else { + handleRemoveRole(r.id); + } + }} + /> + ))} + {(selectedGroup.directRoles ?? []).length === 0 && ( + (no roles) + )} + - ))} - {(selectedGroup.directRoles ?? []).length === 0 && ( - (no roles) - )} - +
) : null diff --git a/ui/src/pages/Admin/UsersTab.tsx b/ui/src/pages/Admin/UsersTab.tsx index 7e7ec05f..50b66890 100644 --- a/ui/src/pages/Admin/UsersTab.tsx +++ b/ui/src/pages/Admin/UsersTab.tsx @@ -35,6 +35,7 @@ import { import type { UserDetail } from '../../api/queries/admin/rbac'; import { useAuthStore } from '../../auth/auth-store'; 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 }) { const { toast } = useToast(); @@ -366,24 +367,27 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig
- Status -
- +
+ Status +
+ +
+ +
+ ID + {selected.userId} + Created + + {new Date(selected.createdAt).toLocaleDateString()} + + Provider + {selected.provider} +
-
- ID - {selected.userId} - Created - - {new Date(selected.createdAt).toLocaleDateString()} - - Provider - {selected.provider} -
- - Security -
+
+ Security +
{selected.provider === 'local' ? ( <>
@@ -445,133 +449,138 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig )} +
- Group membership (direct only) -
- {selected.directGroups.map((g) => ( - { - removeFromGroup.mutate( - { userId: selected.userId, groupId: g.id }, - { - onSuccess: () => - toast({ title: 'Group removed', variant: 'success' }), - onError: () => - toast({ - title: 'Failed to remove group', - variant: 'error', - duration: 86_400_000, - }), - }, - ); - }} - /> - ))} - {selected.directGroups.length === 0 && ( - (no groups) - )} - { - 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" - /> -
- - - Effective roles (direct + inherited) - -
- {selected.directRoles.map((r) => ( - { - 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) => ( - - ))} - {selected.directRoles.length === 0 && - inheritedRoles.length === 0 && ( - (no roles) +
+ Group membership (direct only) +
+ {selected.directGroups.map((g) => ( + { + removeFromGroup.mutate( + { userId: selected.userId, groupId: g.id }, + { + onSuccess: () => + toast({ title: 'Group removed', variant: 'success' }), + onError: () => + toast({ + title: 'Failed to remove group', + variant: 'error', + duration: 86_400_000, + }), + }, + ); + }} + /> + ))} + {selected.directGroups.length === 0 && ( + (no groups) )} - { - 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" - /> + { + 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" + /> +
+
+ +
+ + Effective roles (direct + inherited) + +
+ {selected.directRoles.map((r) => ( + { + 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) => ( + + ))} + {selected.directRoles.length === 0 && + inheritedRoles.length === 0 && ( + (no roles) + )} + { + 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" + /> +
+ {inheritedRoles.length > 0 && ( + + Roles with ↑ are inherited through group membership + + )}
- {inheritedRoles.length > 0 && ( - - Roles with ↑ are inherited through group membership - - )} ) : null } diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 2cff185f..2c43c55e 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -8,6 +8,7 @@ import { } from '@cameleer/design-system'; import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system'; import styles from './AgentHealth.module.css'; +import sectionStyles from '../../styles/section-card.module.css'; import logStyles from '../../styles/log-panel.module.css'; import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useApplicationLogs } from '../../api/queries/logs'; @@ -353,7 +354,7 @@ export default function AgentHealth() { {/* Application config bar */} {appId && appConfig && ( -
+
{configEditing ? ( <>
@@ -569,7 +570,7 @@ export default function AgentHealth() { )}
-
+
Timeline
diff --git a/ui/src/pages/AgentInstance/AgentInstance.module.css b/ui/src/pages/AgentInstance/AgentInstance.module.css index f7198378..e437f2b1 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.module.css +++ b/ui/src/pages/AgentInstance/AgentInstance.module.css @@ -44,12 +44,8 @@ font-family: var(--font-mono); } -/* Process info card */ +/* Process info card — card styling via sectionStyles.section */ .processCard { - background: var(--bg-surface); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); padding: 16px; margin-bottom: 20px; } @@ -116,12 +112,8 @@ gap: 14px; } -/* Timeline card */ +/* Timeline card — card styling via sectionStyles.section */ .timelineCard { - background: var(--bg-surface); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); overflow: hidden; display: flex; flex-direction: column; diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx index f1f6d831..ba583f45 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.tsx +++ b/ui/src/pages/AgentInstance/AgentInstance.tsx @@ -8,6 +8,7 @@ import { } from '@cameleer/design-system'; import type { FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system'; import styles from './AgentInstance.module.css'; +import sectionStyles from '../../styles/section-card.module.css'; import logStyles from '../../styles/log-panel.module.css'; import chartCardStyles from '../../styles/chart-card.module.css'; import { useAgents, useAgentEvents } from '../../api/queries/agents'; @@ -238,7 +239,7 @@ export default function AgentInstance() {
{/* Process info card */} -
+
Process Information
{agent.capabilities?.jvmVersion && ( @@ -438,7 +439,7 @@ export default function AgentInstance() { )}
-
+
Timeline
diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx index 112cae58..58bbe14f 100644 --- a/ui/src/pages/Dashboard/Dashboard.tsx +++ b/ui/src/pages/Dashboard/Dashboard.tsx @@ -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 { AlertTriangle, X, Search, Footprints, RotateCcw } from 'lucide-react' import { @@ -113,7 +113,17 @@ function buildBaseColumns(): Column[] { header: 'Exchange ID', sortable: true, render: (_: unknown, row: Row) => ( - {row.executionId} + { + e.stopPropagation(); + navigator.clipboard.writeText(row.executionId); + }} + > + ...{row.executionId.slice(-8)} + ), }, { diff --git a/ui/src/pages/DashboardTab/DashboardL2.tsx b/ui/src/pages/DashboardTab/DashboardL2.tsx index b334b6a3..aef35faa 100644 --- a/ui/src/pages/DashboardTab/DashboardL2.tsx +++ b/ui/src/pages/DashboardTab/DashboardL2.tsx @@ -420,7 +420,7 @@ export default function DashboardL2() { {/* Top 5 Errors — hidden when empty */} {errorRows.length > 0 && ( -
+
Top Errors {errorRows.length} error types diff --git a/ui/src/pages/DashboardTab/DashboardL3.tsx b/ui/src/pages/DashboardTab/DashboardL3.tsx index b3143ddd..47b7f181 100644 --- a/ui/src/pages/DashboardTab/DashboardL3.tsx +++ b/ui/src/pages/DashboardTab/DashboardL3.tsx @@ -392,7 +392,7 @@ export default function DashboardL3() { {/* Process Diagram with Latency Heatmap */} {appId && routeId && ( -
+
+ + setKillTarget(null)} + onConfirm={() => { + if (killTarget !== null) { + killQuery.mutate(killTarget, { + onSuccess: () => { + toast({ title: 'Query killed', description: `PID ${killTarget}`, variant: 'success' }); + setKillTarget(null); + }, + onError: () => { + toast({ title: 'Failed to kill query', variant: 'error', duration: 86_400_000 }); + setKillTarget(null); + }, + }); + } + }} + title="Kill query?" + description={`Terminate the query running on PID ${killTarget}? This will cancel the operation.`} + confirmLabel="Kill" + variant="warning" + />
); } diff --git a/ui/src/pages/Admin/OidcConfigPage.tsx b/ui/src/pages/Admin/OidcConfigPage.tsx index e8eb357c..cdfad6d1 100644 --- a/ui/src/pages/Admin/OidcConfigPage.tsx +++ b/ui/src/pages/Admin/OidcConfigPage.tsx @@ -261,6 +261,7 @@ export default function OidcConfigPage() { onConfirm={handleDelete} message="Delete OIDC configuration? All users signed in via OIDC will lose access." confirmText="delete oidc" + loading={saving} />
diff --git a/ui/src/pages/Admin/UsersTab.tsx b/ui/src/pages/Admin/UsersTab.tsx index 50b66890..5d4e5f2c 100644 --- a/ui/src/pages/Admin/UsersTab.tsx +++ b/ui/src/pages/Admin/UsersTab.tsx @@ -49,6 +49,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig const [creating, setCreating] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [removeGroupTarget, setRemoveGroupTarget] = useState(null); + const [removeRoleTarget, setRemoveRoleTarget] = useState<{ id: string; name: string } | null>(null); // Auto-select highlighted item from cmd-k navigation useEffect(() => { @@ -515,25 +516,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig 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, - }), - }, - ); - }} + onRemove={() => setRemoveRoleTarget(r)} /> ))} {inheritedRoles.map((r) => ( @@ -621,6 +604,31 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig confirmLabel="Remove" variant="warning" /> + setRemoveRoleTarget(null)} + onConfirm={() => { + if (removeRoleTarget && selected) { + removeRole.mutate( + { userId: selected.userId, roleId: removeRoleTarget.id }, + { + onSuccess: () => { + toast({ title: 'Role removed', description: removeRoleTarget.name, variant: 'success' }); + setRemoveRoleTarget(null); + }, + onError: () => { + toast({ title: 'Failed to remove role', variant: 'error', duration: 86_400_000 }); + setRemoveRoleTarget(null); + }, + }, + ); + } + }} + title="Remove role" + description={`Remove the "${removeRoleTarget?.name}" role from this user?`} + confirmLabel="Remove" + variant="warning" + /> ); } diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index 9140af35..e03df4be 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -1,6 +1,7 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { useParams, useNavigate, useLocation } from 'react-router'; import { + AlertDialog, Badge, Button, ConfirmDialog, @@ -506,6 +507,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s const fileInputRef = useRef(null); const [subTab, setSubTab] = useState<'overview' | 'config'>('config'); const [deleteConfirm, setDeleteConfirm] = useState(false); + const [stopTarget, setStopTarget] = useState<{ id: string; name: string } | null>(null); const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]); const sortedVersions = useMemo(() => [...versions].sort((a, b) => b.version - a.version), [versions]); @@ -531,11 +533,17 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s } catch { toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 }); } } - async function handleStop(deploymentId: string) { + function handleStop(deploymentId: string) { + setStopTarget({ id: deploymentId, name: app?.displayName ?? appSlug }); + } + + async function confirmStop() { + if (!stopTarget) return; try { - await stopDeployment.mutateAsync({ appId: appSlug, deploymentId }); + await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id }); toast({ title: 'Deployment stopped', variant: 'warning' }); } catch { toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); } + setStopTarget(null); } async function handleDelete() { @@ -602,6 +610,15 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s confirmText={app.slug} loading={deleteApp.isPending} /> + setStopTarget(null)} + onConfirm={confirmStop} + title="Stop deployment?" + description={`Stop deployment for "${stopTarget?.name}"? This will take the service offline.`} + confirmLabel="Stop" + variant="warning" + />
); } diff --git a/ui/src/pages/Routes/RouteDetail.tsx b/ui/src/pages/Routes/RouteDetail.tsx index 042adb04..1ae332c6 100644 --- a/ui/src/pages/Routes/RouteDetail.tsx +++ b/ui/src/pages/Routes/RouteDetail.tsx @@ -982,7 +982,7 @@ export default function RouteDetail() {
- +
@@ -998,6 +998,7 @@ export default function RouteDetail() { confirmText={deletingTap?.attributeName ?? ''} confirmLabel="Delete" variant="danger" + loading={updateConfig.isPending} />
); From 2ede06f32ada69810e276daaa2d001dce4b62031 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:42:10 +0200 Subject: [PATCH 12/18] fix: chart Y-axis auto-scaling, error rate unit, memory reference line, pointer events - Throughput chart: divide totalCount by bucket duration (seconds) so Y-axis shows true msg/s instead of raw bucket counts; fixes flat-line appearance when TPS is low but totalCount is large - Error Rate chart: convert failedCount/totalCount to percentage; change yLabel from "err/h" to "%" to match KPI stat card unit - Memory chart: add threshold line at jvm.memory.heap.max so chart Y-axis extends to max heap and shows the reference line (spec 5.3) - Agent state: suppress containerStatus badge when value is "UNKNOWN"; only render it with "Container: " label when a non-UNKNOWN secondary state is present (spec 5.4) - DashboardTab chartGrid: add pointer-events:none with pointer-events:auto on children so the chart grid overlay does not intercept clicks on the Application Health table rows below (spec 5.5) Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/AgentInstance/AgentInstance.tsx | 40 ++++++++++++------- .../DashboardTab/DashboardTab.module.css | 5 +++ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx index ba583f45..4649b5e3 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.tsx +++ b/ui/src/pages/AgentInstance/AgentInstance.tsx @@ -66,16 +66,20 @@ export default function AgentInstance() { 60, ); - const chartData = useMemo( - () => - (timeseries?.buckets || []).map((b: any) => ({ - time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), - throughput: b.totalCount, - latency: b.avgDurationMs, - errors: b.failedCount, - })), - [timeseries], - ); + const chartData = useMemo(() => { + const buckets: any[] = timeseries?.buckets || []; + // Compute bucket duration in seconds from consecutive timestamps (for msg/s conversion) + const bucketSecs = + buckets.length >= 2 + ? (new Date(buckets[1].timestamp).getTime() - new Date(buckets[0].timestamp).getTime()) / 1000 + : 60; + return buckets.map((b: any) => ({ + time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + throughput: bucketSecs > 0 ? b.totalCount / bucketSecs : b.totalCount, + latency: b.avgDurationMs, + errorPct: b.totalCount > 0 ? (b.failedCount / b.totalCount) * 100 : 0, + })); + }, [timeseries]); const feedEvents = useMemo(() => { const mapped = (events || []) @@ -118,7 +122,7 @@ export default function AgentInstance() { const throughputSeries = useMemo( () => chartData.length - ? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }] + ? [{ label: 'msg/s', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }] : null, [chartData], ); @@ -126,7 +130,7 @@ export default function AgentInstance() { const errorSeries = useMemo( () => chartData.length - ? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }] + ? [{ label: 'Error %', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errorPct })) }] : null, [chartData], ); @@ -229,6 +233,9 @@ export default function AgentInstance() { {agent.displayName} + {agent.containerStatus && agent.containerStatus !== 'UNKNOWN' && ( + + )} {agent.version && }
{heapSeries ? ( - + ) : ( )} @@ -352,7 +364,7 @@ export default function AgentInstance() {
{errorSeries ? ( - + ) : ( )} diff --git a/ui/src/pages/DashboardTab/DashboardTab.module.css b/ui/src/pages/DashboardTab/DashboardTab.module.css index 2c6d0f16..ce114578 100644 --- a/ui/src/pages/DashboardTab/DashboardTab.module.css +++ b/ui/src/pages/DashboardTab/DashboardTab.module.css @@ -14,6 +14,11 @@ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; + pointer-events: none; +} + +.chartGrid > * { + pointer-events: auto; } .chartRow { From 605c8ad2702f5ef664a10038cf82616f3c599222 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:43:46 +0200 Subject: [PATCH 13/18] feat: add CSV export to audit log --- ui/src/pages/Admin/AppConfigDetailPage.tsx | 2 +- ui/src/pages/Admin/AuditLogPage.tsx | 21 ++- ui/src/pages/Admin/EnvironmentsPage.tsx | 4 +- ui/src/pages/Admin/GroupsTab.tsx | 3 +- ui/src/pages/Admin/OidcConfigPage.tsx | 158 +++++++++++++-------- ui/src/pages/Admin/RolesTab.tsx | 3 +- ui/src/pages/Admin/UsersTab.tsx | 4 +- ui/src/pages/AppsTab/AppsTab.tsx | 12 +- 8 files changed, 130 insertions(+), 77 deletions(-) diff --git a/ui/src/pages/Admin/AppConfigDetailPage.tsx b/ui/src/pages/Admin/AppConfigDetailPage.tsx index 0c4d69af..3e4463d4 100644 --- a/ui/src/pages/Admin/AppConfigDetailPage.tsx +++ b/ui/src/pages/Admin/AppConfigDetailPage.tsx @@ -160,7 +160,7 @@ export default function AppConfigDetailPage() { } }, onError: () => { - toast({ title: 'Save failed', description: 'Could not update configuration', variant: 'error', duration: 86_400_000 }); + toast({ title: 'Failed to save configuration', description: 'Could not update configuration', variant: 'error', duration: 86_400_000 }); }, }); } diff --git a/ui/src/pages/Admin/AuditLogPage.tsx b/ui/src/pages/Admin/AuditLogPage.tsx index 2c1ca4f5..1a9aeb2d 100644 --- a/ui/src/pages/Admin/AuditLogPage.tsx +++ b/ui/src/pages/Admin/AuditLogPage.tsx @@ -1,8 +1,9 @@ import { useState, useMemo, useCallback } from 'react'; import { - Badge, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable, + Badge, Button, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable, } from '@cameleer/design-system'; import type { Column } from '@cameleer/design-system'; +import { Download } from 'lucide-react'; import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit'; import styles from './AuditLogPage.module.css'; import tableStyles from '../../styles/table-section.module.css'; @@ -17,6 +18,21 @@ const CATEGORIES = [ { value: 'AGENT', label: 'AGENT' }, ]; +function exportCsv(events: AuditEvent[]) { + const headers = ['Timestamp', 'User', 'Category', 'Action', 'Target', 'Result', 'Details']; + const rows = events.map(e => [ + e.timestamp, e.username, e.category, e.action, e.target, e.result, e.details ?? '', + ]); + const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `cameleer-audit-${new Date().toISOString().slice(0, 16).replace(':', '-')}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + function formatTimestamp(iso: string): string { return new Date(iso).toLocaleString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit', @@ -126,6 +142,9 @@ export default function AuditLogPage() { {totalCount} events +
; + if (isLoading) return ; return ( <> diff --git a/ui/src/pages/Admin/GroupsTab.tsx b/ui/src/pages/Admin/GroupsTab.tsx index e6a8967d..6a2b9c06 100644 --- a/ui/src/pages/Admin/GroupsTab.tsx +++ b/ui/src/pages/Admin/GroupsTab.tsx @@ -31,6 +31,7 @@ import { useRoles, } from '../../api/queries/admin/rbac'; import type { GroupDetail } from '../../api/queries/admin/rbac'; +import { PageLoader } from '../../components/PageLoader'; import styles from './UserManagement.module.css'; import sectionStyles from '../../styles/section-card.module.css'; @@ -225,7 +226,7 @@ export default function GroupsTab({ highlightId, onHighlightConsumed }: { highli } } - if (groupsLoading) return ; + if (groupsLoading) return ; return ( <> diff --git a/ui/src/pages/Admin/OidcConfigPage.tsx b/ui/src/pages/Admin/OidcConfigPage.tsx index cdfad6d1..1dd316c2 100644 --- a/ui/src/pages/Admin/OidcConfigPage.tsx +++ b/ui/src/pages/Admin/OidcConfigPage.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; import { - Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, Alert, + Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, } from '@cameleer/design-system'; import { useToast } from '@cameleer/design-system'; +import { PageLoader } from '../../components/PageLoader'; import { adminFetch } from '../../api/queries/admin/admin-api'; import styles from './OidcConfigPage.module.css'; import sectionStyles from '../../styles/section-card.module.css'; @@ -37,11 +38,12 @@ const EMPTY_CONFIG: OidcFormData = { export default function OidcConfigPage() { const [form, setForm] = useState(null); + const [editing, setEditing] = useState(false); + const [formDraft, setFormDraft] = useState(null); const [newRole, setNewRole] = useState(''); const [deleteOpen, setDeleteOpen] = useState(false); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); - const [error, setError] = useState(null); const { toast } = useToast(); useEffect(() => { @@ -62,34 +64,49 @@ export default function OidcConfigPage() { .catch(() => setForm(EMPTY_CONFIG)); }, []); - function update(key: K, value: OidcFormData[K]) { - setForm((prev) => prev ? { ...prev, [key]: value } : prev); + // The display values come from formDraft when editing, form otherwise + const current = editing ? formDraft : form; + + function startEditing() { + setFormDraft(form ? { ...form } : null); + setEditing(true); + } + + function cancelEditing() { + setFormDraft(null); + setEditing(false); + setNewRole(''); + } + + function updateDraft(key: K, value: OidcFormData[K]) { + setFormDraft((prev) => prev ? { ...prev, [key]: value } : prev); } function addRole() { - if (!form) return; + if (!current) return; const role = newRole.trim().toUpperCase(); - if (role && !(form.defaultRoles || []).includes(role)) { - update('defaultRoles', [...(form.defaultRoles || []), role]); + if (role && !(current.defaultRoles || []).includes(role)) { + updateDraft('defaultRoles', [...(current.defaultRoles || []), role]); setNewRole(''); } } function removeRole(role: string) { - if (!form) return; - update('defaultRoles', (form.defaultRoles || []).filter((r) => r !== role)); + if (!current) return; + updateDraft('defaultRoles', (current.defaultRoles || []).filter((r) => r !== role)); } async function handleSave() { - if (!form) return; + if (!formDraft) return; setSaving(true); - setError(null); try { - await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(form) }); + await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(formDraft) }); + setForm({ ...formDraft }); + setFormDraft(null); + setEditing(false); toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' }); } catch (e: any) { - setError(e.message); - toast({ title: 'Save failed', description: e.message, variant: 'error', duration: 86_400_000 }); + toast({ title: 'Failed to save OIDC configuration', description: e.message, variant: 'error', duration: 86_400_000 }); } finally { setSaving(false); } @@ -98,12 +115,10 @@ export default function OidcConfigPage() { async function handleTest() { if (!form) return; setTesting(true); - setError(null); try { const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' }); toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' }); } catch (e: any) { - setError(e.message); toast({ title: 'Connection test failed', description: e.message, variant: 'error', duration: 86_400_000 }); } finally { setTesting(false); @@ -112,46 +127,53 @@ export default function OidcConfigPage() { async function handleDelete() { setDeleteOpen(false); - setError(null); try { await adminFetch('/oidc', { method: 'DELETE' }); setForm(EMPTY_CONFIG); + setFormDraft(null); + setEditing(false); toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' }); } catch (e: any) { - setError(e.message); - toast({ title: 'Delete failed', description: e.message, variant: 'error', duration: 86_400_000 }); + toast({ title: 'Failed to delete OIDC configuration', description: e.message, variant: 'error', duration: 86_400_000 }); } } - if (!form) return null; + if (!form) return ; return (
- - + {editing ? ( + <> + + + + ) : ( + <> + + + + )}
- {error &&
{error}
} -
Behavior
update('enabled', e.target.checked)} + checked={current?.enabled ?? false} + onChange={(e) => updateDraft('enabled', e.target.checked)} + disabled={!editing} />
update('autoSignup', e.target.checked)} + checked={current?.autoSignup ?? true} + onChange={(e) => updateDraft('autoSignup', e.target.checked)} + disabled={!editing} /> Automatically create accounts for new OIDC users
@@ -164,39 +186,44 @@ export default function OidcConfigPage() { id="issuer" type="url" placeholder="https://idp.example.com/realms/my-realm" - value={form.issuerUri} - onChange={(e) => update('issuerUri', e.target.value)} + value={current?.issuerUri ?? ''} + onChange={(e) => updateDraft('issuerUri', e.target.value)} + disabled={!editing} /> update('clientId', e.target.value)} + value={current?.clientId ?? ''} + onChange={(e) => updateDraft('clientId', e.target.value)} + disabled={!editing} /> update('clientSecret', e.target.value)} + value={current?.clientSecret ?? ''} + onChange={(e) => updateDraft('clientSecret', e.target.value)} + disabled={!editing} /> update('audience', e.target.value)} + value={current?.audience ?? ''} + onChange={(e) => updateDraft('audience', e.target.value)} + disabled={!editing} /> update('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))} + value={(current?.additionalScopes || []).join(', ')} + onChange={(e) => updateDraft('additionalScopes', e.target.value.split(',').map(s => s.trim()).filter(Boolean))} + disabled={!editing} />
@@ -206,22 +233,25 @@ export default function OidcConfigPage() { update('rolesClaim', e.target.value)} + value={current?.rolesClaim ?? ''} + onChange={(e) => updateDraft('rolesClaim', e.target.value)} + disabled={!editing} /> update('userIdClaim', e.target.value)} + value={current?.userIdClaim ?? ''} + onChange={(e) => updateDraft('userIdClaim', e.target.value)} + disabled={!editing} /> update('displayNameClaim', e.target.value)} + value={current?.displayNameClaim ?? ''} + onChange={(e) => updateDraft('displayNameClaim', e.target.value)} + disabled={!editing} /> @@ -229,25 +259,27 @@ export default function OidcConfigPage() {
Default Roles
- {(form.defaultRoles || []).map((role) => ( - removeRole(role)} /> + {(current?.defaultRoles || []).map((role) => ( + removeRole(role) : undefined} /> ))} - {(form.defaultRoles || []).length === 0 && ( + {(current?.defaultRoles || []).length === 0 && ( No default roles configured )}
-
- setNewRole(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }} - className={styles.roleInput} - /> - -
+ {editing && ( +
+ setNewRole(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }} + className={styles.roleInput} + /> + +
+ )}
diff --git a/ui/src/pages/Admin/RolesTab.tsx b/ui/src/pages/Admin/RolesTab.tsx index e264598d..514551fb 100644 --- a/ui/src/pages/Admin/RolesTab.tsx +++ b/ui/src/pages/Admin/RolesTab.tsx @@ -20,6 +20,7 @@ import { useDeleteRole, } from '../../api/queries/admin/rbac'; import type { RoleDetail } from '../../api/queries/admin/rbac'; +import { PageLoader } from '../../components/PageLoader'; import styles from './UserManagement.module.css'; export default function RolesTab({ highlightId, onHighlightConsumed }: { highlightId?: string | null; onHighlightConsumed?: () => void }) { @@ -115,7 +116,7 @@ export default function RolesTab({ highlightId, onHighlightConsumed }: { highlig ); } - if (isLoading) return ; + if (isLoading) return ; return ( <> diff --git a/ui/src/pages/Admin/UsersTab.tsx b/ui/src/pages/Admin/UsersTab.tsx index 5d4e5f2c..34dbc278 100644 --- a/ui/src/pages/Admin/UsersTab.tsx +++ b/ui/src/pages/Admin/UsersTab.tsx @@ -16,7 +16,6 @@ import { AlertDialog, SplitPane, EntityList, - Spinner, useToast, } from '@cameleer/design-system'; import { @@ -34,6 +33,7 @@ import { } from '../../api/queries/admin/rbac'; import type { UserDetail } from '../../api/queries/admin/rbac'; import { useAuthStore } from '../../auth/auth-store'; +import { PageLoader } from '../../components/PageLoader'; import styles from './UserManagement.module.css'; import sectionStyles from '../../styles/section-card.module.css'; @@ -200,7 +200,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig return user.directGroups.map((g) => g.name).join(', '); } - if (isLoading) return ; + if (isLoading) return ; return ( <> diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index e03df4be..2b592f08 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -10,7 +10,6 @@ import { MonoText, SectionHeader, Select, - Spinner, StatusDot, Tabs, Toggle, @@ -40,6 +39,7 @@ import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog'; import { DeploymentProgress } from '../../components/DeploymentProgress'; import { timeAgo } from '../../utils/format-utils'; import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils'; +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'; @@ -117,7 +117,7 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde }, ], [selectedEnv]); - if (isLoading) return ; + if (isLoading) return ; return (
@@ -512,7 +512,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]); const sortedVersions = useMemo(() => [...versions].sort((a, b) => b.version - a.version), [versions]); - if (!app) return ; + if (!app) return ; const env = envMap.get(app.environmentId); @@ -522,7 +522,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s try { const v = await uploadJar.mutateAsync({ appId: appSlug, file }); toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' }); - } catch { toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 }); } + } catch { toast({ title: 'Failed to upload JAR', variant: 'error', duration: 86_400_000 }); } if (fileInputRef.current) fileInputRef.current.value = ''; } @@ -530,7 +530,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s try { await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId }); toast({ title: 'Deployment started', variant: 'success' }); - } catch { toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 }); } + } catch { toast({ title: 'Failed to deploy application', variant: 'error', duration: 86_400_000 }); } } function handleStop(deploymentId: string) { @@ -542,7 +542,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s try { await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id }); toast({ title: 'Deployment stopped', variant: 'warning' }); - } catch { toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); } + } catch { toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 }); } setStopTarget(null); } From 7ec56f3bd07f12deb4ac0f4dfee7e6acf9cf764b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:43:52 +0200 Subject: [PATCH 14/18] fix: add shared number formatting utilities (formatMetric, formatCount, formatPercent) Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/TabKpis.tsx | 7 ++----- ui/src/utils/format-utils.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/ui/src/components/TabKpis.tsx b/ui/src/components/TabKpis.tsx index 58041f46..edc197ed 100644 --- a/ui/src/components/TabKpis.tsx +++ b/ui/src/components/TabKpis.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { useGlobalFilters } from '@cameleer/design-system'; import { useExecutionStats } from '../api/queries/executions'; import type { Scope } from '../hooks/useScope'; +import { formatPercent } from '../utils/format-utils'; import styles from './TabKpis.module.css'; interface TabKpisProps { @@ -20,10 +21,6 @@ function formatMs(ms: number): string { return `${Math.round(ms)}ms`; } -function formatPct(pct: number): string { - return `${pct.toFixed(1)}%`; -} - type Trend = 'up' | 'down' | 'flat'; function trend(current: number, previous: number): Trend { @@ -74,7 +71,7 @@ export function TabKpis({ scope }: TabKpisProps) { return [ { label: 'Total', value: formatNum(total), trend: trend(total, prevTotal) }, - { label: 'Err%', value: formatPct(errorRate), trend: trend(errorRate, prevErrorRate), upIsBad: true }, + { 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 }, ]; diff --git a/ui/src/utils/format-utils.ts b/ui/src/utils/format-utils.ts index 04a07745..d05d5172 100644 --- a/ui/src/utils/format-utils.ts +++ b/ui/src/utils/format-utils.ts @@ -39,3 +39,20 @@ export function timeAgo(iso?: string): string { if (hours < 24) return `${hours}h ago`; return `${Math.floor(hours / 24)}d ago`; } + +export function formatMetric(value: number, unit: string, decimals = 1): string { + if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(decimals)}M ${unit}`; + if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(decimals)}K ${unit}`; + if (Number.isInteger(value)) return `${value} ${unit}`; + return `${value.toFixed(decimals)} ${unit}`; +} + +export function formatCount(value: number): string { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(value); +} + +export function formatPercent(value: number, decimals = 1): string { + return `${value.toFixed(decimals)}%`; +} From 39687bc8a95ffdedda72a64e7652da4ad132c06e Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:46:30 +0200 Subject: [PATCH 15/18] fix: fix unicode in roles, add password confirmation field - RolesTab: wrap \u00b7 in JS expression {'\u00b7'} so JSX renders the middle dot correctly instead of literal backslash-u sequence - UsersTab: add confirm password field with mismatch validation, hint text for password policy, and reset on cancel/success - UserManagement.module.css: add .hintText style for password policy hint Co-Authored-By: Claude Sonnet 4.6 --- ui/src/pages/Admin/RolesTab.tsx | 2 +- ui/src/pages/Admin/UserManagement.module.css | 5 +++ ui/src/pages/Admin/UsersTab.tsx | 33 +++++++++++++++----- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/ui/src/pages/Admin/RolesTab.tsx b/ui/src/pages/Admin/RolesTab.tsx index 514551fb..c616cc31 100644 --- a/ui/src/pages/Admin/RolesTab.tsx +++ b/ui/src/pages/Admin/RolesTab.tsx @@ -179,7 +179,7 @@ export default function RolesTab({ highlightId, onHighlightConsumed }: { highlig )}
- {role.description || '\u2014'} \u00b7{' '} + {role.description || '\u2014'} {'\u00b7'}{' '} {getAssignmentCount(role)} assignments
diff --git a/ui/src/pages/Admin/UserManagement.module.css b/ui/src/pages/Admin/UserManagement.module.css index b4864871..d2b0b423 100644 --- a/ui/src/pages/Admin/UserManagement.module.css +++ b/ui/src/pages/Admin/UserManagement.module.css @@ -156,3 +156,8 @@ color: var(--error); font-size: 12px; } + +.hintText { + font-size: 12px; + color: var(--text-muted); +} diff --git a/ui/src/pages/Admin/UsersTab.tsx b/ui/src/pages/Admin/UsersTab.tsx index 34dbc278..c30808cb 100644 --- a/ui/src/pages/Admin/UsersTab.tsx +++ b/ui/src/pages/Admin/UsersTab.tsx @@ -67,8 +67,11 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig const [newDisplay, setNewDisplay] = useState(''); const [newEmail, setNewEmail] = useState(''); const [newPassword, setNewPassword] = useState(''); + const [newPasswordConfirm, setNewPasswordConfirm] = useState(''); const [newProvider, setNewProvider] = useState<'local' | 'oidc'>('local'); + const passwordMismatch = newPassword.length > 0 && newPasswordConfirm.length > 0 && newPassword !== newPasswordConfirm; + // Password reset state const [resettingPassword, setResettingPassword] = useState(false); const [newPw, setNewPw] = useState(''); @@ -145,6 +148,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig setNewDisplay(''); setNewEmail(''); setNewPassword(''); + setNewPasswordConfirm(''); setNewProvider('local'); }, onError: (err: unknown) => { @@ -241,12 +245,24 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig onChange={(e) => setNewEmail(e.target.value)} /> {newProvider === 'local' && ( - setNewPassword(e.target.value)} - /> + <> + setNewPassword(e.target.value)} + /> + setNewPasswordConfirm(e.target.value)} + /> + {passwordMismatch && ( + Passwords do not match + )} + Min 12 chars, 3 of 4: uppercase, lowercase, number, special + )} {newProvider === 'oidc' && ( @@ -258,7 +274,7 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig @@ -270,7 +286,8 @@ export default function UsersTab({ highlightId, onHighlightConsumed }: { highlig disabled={ !newUsername.trim() || (newProvider === 'local' && !newPassword.trim()) || - duplicateUsername + duplicateUsername || + passwordMismatch } > Create From 94665510447af41aa4a3695bf488ce405f8f734f Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:47:55 +0200 Subject: [PATCH 16/18] fix: add unsaved changes banners to edit mode forms Adds amber edit-mode banners to AppConfigDetailPage and both DefaultResourcesSection/JarRetentionSection in EnvironmentsPage, matching the existing ConfigSubTab pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../Admin/AppConfigDetailPage.module.css | 10 +++++++ ui/src/pages/Admin/AppConfigDetailPage.tsx | 6 ++++ ui/src/pages/Admin/EnvironmentsPage.tsx | 10 +++++++ ui/src/pages/Admin/OidcConfigPage.tsx | 28 ++++++++++++++----- ui/src/pages/Admin/UserManagement.module.css | 10 +++++++ ui/src/pages/AppsTab/AppsTab.tsx | 11 ++++---- ui/src/pages/Routes/RouteDetail.tsx | 17 ++++------- 7 files changed, 68 insertions(+), 24 deletions(-) diff --git a/ui/src/pages/Admin/AppConfigDetailPage.module.css b/ui/src/pages/Admin/AppConfigDetailPage.module.css index 6536bedf..b5a2bde9 100644 --- a/ui/src/pages/Admin/AppConfigDetailPage.module.css +++ b/ui/src/pages/Admin/AppConfigDetailPage.module.css @@ -90,3 +90,13 @@ color: var(--text-muted); font-family: var(--font-body); } + +.editBanner { + padding: 8px 16px; + background: color-mix(in srgb, var(--amber) 8%, transparent); + border: 1px solid var(--amber); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--text-primary); + margin-bottom: 16px; +} diff --git a/ui/src/pages/Admin/AppConfigDetailPage.tsx b/ui/src/pages/Admin/AppConfigDetailPage.tsx index 3e4463d4..b8584e24 100644 --- a/ui/src/pages/Admin/AppConfigDetailPage.tsx +++ b/ui/src/pages/Admin/AppConfigDetailPage.tsx @@ -317,6 +317,12 @@ export default function AppConfigDetailPage() { )}
+ {editing && ( +
+ Editing configuration. Changes are not saved until you click Save. +
+ )} +

{appId}

diff --git a/ui/src/pages/Admin/EnvironmentsPage.tsx b/ui/src/pages/Admin/EnvironmentsPage.tsx index 30d80f5b..678c129a 100644 --- a/ui/src/pages/Admin/EnvironmentsPage.tsx +++ b/ui/src/pages/Admin/EnvironmentsPage.tsx @@ -392,6 +392,11 @@ function DefaultResourcesSection({ environment, onSave, saving }: { return (
Default Resource Limits + {editing && ( +
+ Editing resource defaults. Changes are not saved until you click Save. +
+ )}

These defaults apply to new apps in this environment unless overridden per-app.

@@ -485,6 +490,11 @@ function JarRetentionSection({ environment, onSave, saving }: { return (
JAR Retention + {editing && ( +
+ Editing resource defaults. Changes are not saved until you click Save. +
+ )}

Old JAR versions are cleaned up nightly. Currently deployed versions are never deleted.

diff --git a/ui/src/pages/Admin/OidcConfigPage.tsx b/ui/src/pages/Admin/OidcConfigPage.tsx index 1dd316c2..c630a787 100644 --- a/ui/src/pages/Admin/OidcConfigPage.tsx +++ b/ui/src/pages/Admin/OidcConfigPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; import { Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, } from '@cameleer/design-system'; @@ -41,6 +42,7 @@ export default function OidcConfigPage() { const [editing, setEditing] = useState(false); const [formDraft, setFormDraft] = useState(null); const [newRole, setNewRole] = useState(''); + const [showSecret, setShowSecret] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -200,13 +202,25 @@ export default function OidcConfigPage() { /> - updateDraft('clientSecret', e.target.value)} - disabled={!editing} - /> +
+ updateDraft('clientSecret', e.target.value)} + disabled={!editing} + /> + {editing && ( + + )} +
Deployments
{deploymentRows.length === 0 - ?

No deployments yet.

+ ? : columns={deploymentColumns} data={deploymentRows} flush /> }
@@ -726,7 +727,7 @@ function OverviewSubTab({ app, deployments, versions, environments, envMap, sele ))} Versions ({versions.length}) - {versions.length === 0 &&

No versions uploaded yet.

} + {versions.length === 0 && } {versions.map((v) => ( onDeploy(v.id, envId)} /> ))} @@ -1005,7 +1006,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen {editing && ( )} - {envVars.length === 0 && !editing &&

No environment variables configured.

} + {envVars.length === 0 && !editing && }
)} @@ -1075,7 +1076,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen {tracedCount} traced · {tapCount} taps {tracedTapRows.length > 0 ? columns={tracedTapColumns} data={tracedTapRows} pageSize={20} flush /> - :

No processor traces or taps configured.

} + : }
)} @@ -1085,7 +1086,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen {recordingCount} of {routeRecordingRows.length} routes recording {routeRecordingRows.length > 0 ? columns={routeRecordingColumns} data={routeRecordingRows} pageSize={20} flush /> - :

No routes found for this application.

} + : }
)} diff --git a/ui/src/pages/Routes/RouteDetail.tsx b/ui/src/pages/Routes/RouteDetail.tsx index 1ae332c6..1f9637ee 100644 --- a/ui/src/pages/Routes/RouteDetail.tsx +++ b/ui/src/pages/Routes/RouteDetail.tsx @@ -6,6 +6,7 @@ import { Badge, StatusDot, DataTable, + EmptyState, Tabs, AreaChart, LineChart, @@ -702,9 +703,7 @@ export default function RouteDetail() { {diagramFlows.length > 0 ? ( ) : ( -
- No diagram available for this route. -
+ )}
@@ -714,9 +713,7 @@ export default function RouteDetail() { ) : processorRows.length > 0 ? ( ) : ( -
- No processor data available. -
+ )}
@@ -819,9 +816,7 @@ export default function RouteDetail() { {activeTab === 'errors' && (
{errorPatterns.length === 0 ? ( -
- No error patterns found in the selected time range. -
+ ) : ( errorPatterns.map((ep, i) => (
@@ -841,9 +836,7 @@ export default function RouteDetail() {
{routeTaps.length === 0 ? ( -
- No taps configured for this route. Add a tap to extract business attributes from exchange data. -
+ ) : ( Date: Thu, 9 Apr 2026 18:51:49 +0200 Subject: [PATCH 17/18] =?UTF-8?q?fix:=20nice-to-have=20polish=20=E2=80=94?= =?UTF-8?q?=20breadcrumbs,=20close=20button,=20status=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 7.1: Add deployment status badge (StatusDot + Badge) to AppsTab app list, sourced from catalog.deployment.status via slug lookup - 7.3: Add X close button to top-right of exchange detail right panel in ExchangesPage (position:absolute, triggers handleClearSelection) - 7.5: PunchcardHeatmap shows "Requires at least 2 days of data" when timeRangeMs < 2 days; DashboardL1 passes the range down - 7.6: Command palette exchange results truncate IDs to ...{last8} matching the exchanges table display Co-Authored-By: Claude Sonnet 4.6 --- ui/src/components/LayoutShell.tsx | 2 +- ui/src/pages/AppsTab/AppsTab.tsx | 28 +++++++++++++++++-- ui/src/pages/DashboardTab/DashboardL1.tsx | 2 +- .../pages/DashboardTab/PunchcardHeatmap.tsx | 14 +++++++++- .../pages/Exchanges/ExchangesPage.module.css | 24 ++++++++++++++++ ui/src/pages/Exchanges/ExchangesPage.tsx | 4 +++ 6 files changed, 69 insertions(+), 5 deletions(-) diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index d5110a0d..f8305daa 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -489,7 +489,7 @@ function LayoutContent() { const exchangeItems: SearchResult[] = (exchangeResults?.data || []).map((e: any) => ({ id: e.executionId, category: 'exchange' as const, - title: e.executionId, + title: `...${e.executionId.slice(-8)}`, badges: [{ label: e.status, color: statusToColor(e.status) }], meta: `${e.routeId} · ${e.applicationId ?? ''} · ${formatDuration(e.durationMs)}`, path: `/exchanges/${e.applicationId ?? ''}/${e.routeId}/${e.executionId}`, diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index c85c2712..e7b40f3a 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -36,7 +36,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 type { CatalogApp, CatalogRoute } from '../../api/queries/catalog'; +import type { CatalogApp } from '../../api/queries/catalog'; import { DeploymentProgress } from '../../components/DeploymentProgress'; import { timeAgo } from '../../utils/format-utils'; import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils'; @@ -89,12 +89,22 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde const { data: allApps = [], isLoading: allLoading } = useAllApps(); const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]); const { data: envApps = [], isLoading: envLoading } = useApps(envId); + const { data: catalog = [] } = useCatalog(selectedEnv); const apps = selectedEnv ? envApps : allApps; const isLoading = selectedEnv ? envLoading : allLoading; const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]); + // Build slug → deployment status map from catalog + const deployStatusMap = useMemo(() => { + const map = new Map(); + for (const app of catalog as CatalogApp[]) { + if (app.deployment) map.set(app.slug, app.deployment.status); + } + return map; + }, [catalog]); + type AppRow = App & { id: string; envName: string }; const rows: AppRow[] = useMemo( () => apps.map((a) => ({ ...a, envName: envMap.get(a.environmentId)?.displayName ?? '?' })), @@ -110,13 +120,27 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde ...(!selectedEnv ? [{ key: 'envName', header: 'Environment', sortable: true, render: (_v: unknown, row: AppRow) => , }] : []), + { key: 'slug', header: 'Status', sortable: false, + render: (_v: unknown, row: AppRow) => { + const status = deployStatusMap.get(row.slug); + if (!status) return ; + const dotVariant = DEPLOY_STATUS_DOT[status] ?? 'dead'; + const badgeColor = STATUS_COLORS[status] ?? 'auto'; + return ( + + + + + ); + }, + }, { key: 'updatedAt', header: 'Updated', sortable: true, render: (_v: unknown, row: AppRow) => {timeAgo(row.updatedAt)}, }, { key: 'createdAt', header: 'Created', sortable: true, render: (_v: unknown, row: AppRow) => {new Date(row.createdAt).toLocaleDateString()}, }, - ], [selectedEnv]); + ], [selectedEnv, deployStatusMap]); if (isLoading) return ; diff --git a/ui/src/pages/DashboardTab/DashboardL1.tsx b/ui/src/pages/DashboardTab/DashboardL1.tsx index 5311d38e..d0468f18 100644 --- a/ui/src/pages/DashboardTab/DashboardL1.tsx +++ b/ui/src/pages/DashboardTab/DashboardL1.tsx @@ -462,7 +462,7 @@ export default function DashboardL1() { /> - +
)} diff --git a/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx b/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx index 0bfaebb6..3381e6f3 100644 --- a/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx +++ b/ui/src/pages/DashboardTab/PunchcardHeatmap.tsx @@ -10,6 +10,7 @@ export interface PunchcardCell { interface PunchcardHeatmapProps { cells: PunchcardCell[]; + timeRangeMs?: number; } type Mode = 'transactions' | 'errors'; @@ -38,8 +39,11 @@ const GAP = 2; const LABEL_W = 28; const LABEL_H = 14; -export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) { +const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000; + +export function PunchcardHeatmap({ cells, timeRangeMs }: PunchcardHeatmapProps) { const [mode, setMode] = useState('transactions'); + const insufficientData = timeRangeMs !== undefined && timeRangeMs < TWO_DAYS_MS; const { grid, maxVal } = useMemo(() => { const cellMap = new Map(); @@ -63,6 +67,14 @@ export function PunchcardHeatmap({ cells }: PunchcardHeatmapProps) { const svgW = LABEL_W + cols * (CELL + GAP); const svgH = LABEL_H + rows * (CELL + GAP); + if (insufficientData) { + return ( +
+ Requires at least 2 days of data +
+ ); + } + return (
diff --git a/ui/src/pages/Exchanges/ExchangesPage.module.css b/ui/src/pages/Exchanges/ExchangesPage.module.css index dac174db..937d6b61 100644 --- a/ui/src/pages/Exchanges/ExchangesPage.module.css +++ b/ui/src/pages/Exchanges/ExchangesPage.module.css @@ -27,6 +27,7 @@ } .rightPanel { + position: relative; flex: 1; display: flex; flex-direction: column; @@ -43,3 +44,26 @@ color: var(--text-muted); font-size: 0.875rem; } + +.closeBtn { + position: absolute; + top: 6px; + right: 8px; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background 0.1s, color 0.1s; +} + +.closeBtn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} diff --git a/ui/src/pages/Exchanges/ExchangesPage.tsx b/ui/src/pages/Exchanges/ExchangesPage.tsx index aa968bf2..03b5f68d 100644 --- a/ui/src/pages/Exchanges/ExchangesPage.tsx +++ b/ui/src/pages/Exchanges/ExchangesPage.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { X } from 'lucide-react'; import { useNavigate, useLocation, useParams } from 'react-router'; import { useGlobalFilters, useToast } from '@cameleer/design-system'; import { useExecutionDetail } from '../../api/queries/executions'; @@ -122,6 +123,9 @@ export default function ExchangesPage() {
+ Date: Thu, 9 Apr 2026 18:57:42 +0200 Subject: [PATCH 18/18] fix: resolve 4 TypeScript compilation errors from CI - AuditLogPage: e.details -> e.detail (correct property name) - AgentInstance: BarChart x: number -> x: String(i) (BarSeries requires string) - AppsTab: add missing CatalogRoute import - Dashboard: wrap MonoText in span for title attribute (MonoText lacks title prop) Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/pages/Admin/AuditLogPage.tsx | 2 +- ui/src/pages/AgentInstance/AgentInstance.tsx | 2 +- ui/src/pages/AppsTab/AppsTab.tsx | 2 +- ui/src/pages/Dashboard/Dashboard.tsx | 7 +++---- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/ui/src/pages/Admin/AuditLogPage.tsx b/ui/src/pages/Admin/AuditLogPage.tsx index 1a9aeb2d..1a0e1aa5 100644 --- a/ui/src/pages/Admin/AuditLogPage.tsx +++ b/ui/src/pages/Admin/AuditLogPage.tsx @@ -21,7 +21,7 @@ const CATEGORIES = [ function exportCsv(events: AuditEvent[]) { const headers = ['Timestamp', 'User', 'Category', 'Action', 'Target', 'Result', 'Details']; const rows = events.map(e => [ - e.timestamp, e.username, e.category, e.action, e.target, e.result, e.details ?? '', + e.timestamp, e.username, e.category, e.action, e.target, e.result, e.detail ?? '', ]); const csv = [headers, ...rows].map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(',')).join('\n'); const blob = new Blob([csv], { type: 'text/csv' }); diff --git a/ui/src/pages/AgentInstance/AgentInstance.tsx b/ui/src/pages/AgentInstance/AgentInstance.tsx index 4649b5e3..02e83f31 100644 --- a/ui/src/pages/AgentInstance/AgentInstance.tsx +++ b/ui/src/pages/AgentInstance/AgentInstance.tsx @@ -116,7 +116,7 @@ export default function AgentInstance() { const gcSeries = useMemo(() => { const pts = jvmMetrics?.metrics?.['jvm.gc.time']; if (!pts?.length) return null; - return [{ label: 'GC ms', data: pts.map((p: any, i: number) => ({ x: i, y: p.value })) }]; + return [{ label: 'GC ms', data: pts.map((p: any, i: number) => ({ x: String(i), y: p.value })) }]; }, [jvmMetrics]); const throughputSeries = useMemo( diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index e7b40f3a..9926b063 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -36,7 +36,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 type { CatalogApp } from '../../api/queries/catalog'; +import type { CatalogApp, CatalogRoute } from '../../api/queries/catalog'; import { DeploymentProgress } from '../../components/DeploymentProgress'; import { timeAgo } from '../../utils/format-utils'; import { applyTracedProcessorUpdate, applyRouteRecordingUpdate } from '../../utils/config-draft-utils'; diff --git a/ui/src/pages/Dashboard/Dashboard.tsx b/ui/src/pages/Dashboard/Dashboard.tsx index 3342ed01..f9a9c82b 100644 --- a/ui/src/pages/Dashboard/Dashboard.tsx +++ b/ui/src/pages/Dashboard/Dashboard.tsx @@ -119,8 +119,7 @@ function buildColumns(hasAttributes: boolean): Column[] { header: 'Exchange ID', sortable: true, render: (_: unknown, row: Row) => ( - { @@ -128,8 +127,8 @@ function buildColumns(hasAttributes: boolean): Column[] { navigator.clipboard.writeText(row.executionId); }} > - ...{row.executionId.slice(-8)} - + ...{row.executionId.slice(-8)} + ), }, {