From 9b4df106591b1ae58bb619aed1a7dad7d0310e15 Mon Sep 17 00:00:00 2001 From: Stefan Date: Tue, 26 May 2026 11:12:21 +0200 Subject: [PATCH] feat(ui): make the overview mobile-friendly The desktop layout was unusable on phones: the 256px sidebar stayed pinned and pushed `main` off-screen at every breakpoint, job-card names were truncated even on wide desktops (a nested 2-column queue keeps cards ~320px), and the floating bulk-action bars ran off both screen edges. Navigation - Sidebar is now an overlay drawer below `lg` (slides in over content with a dimmed backdrop, auto-closes on navigation); the `main` offset is `lg:`-only so mobile uses full width. Desktop keeps the pinned, collapsible sidebar. Adds a transient `mobileNavOpen` UI state. - Header has separate mobile (drawer) / desktop (collapse) toggles and trims the wordmark/status-label/username to fit narrow screens. Job cards - JobCard is now a container (`@container`): the name wraps instead of truncating, the badge row wraps, the meta row stacks when narrow, and the action icons collapse into a labelled overflow menu on narrow cards (inline icons on wide ones). Fixes mobile and the narrow-desktop-queue truncation in one place. Group page - Header title/actions stack on mobile; bulk-action bars are contained and wrap on mobile, centered-floating on desktop. - Template cards are container-driven (full-width wrapping name + action row below on mobile, side-by-side on desktop) and a horizontal-overflow bug from a bare grid track + long
 is fixed.

Other pages
- JobDetailDialog field grids stack on small screens; long titles wrap.
- Runners table scrolls horizontally on mobile (was clipping the Labels
  and Last Seen columns); header counts wrap.

Verified at 390 / 768 / 1024 / 1440 px across all tabs with no horizontal
overflow.

Also carries the pre-existing benchmarkoor run-link work that was already
in the working tree (config.ts / config.json and the card links).
---
 ui/public/config.json                        |   3 +-
 ui/src/components/jobs/JobCard.tsx           | 361 ++++++++++++-------
 ui/src/components/jobs/JobDetailDialog.tsx   |  33 +-
 ui/src/components/layout/Header.tsx          |  36 +-
 ui/src/components/layout/Layout.tsx          |   2 +-
 ui/src/components/layout/Sidebar.tsx         | 173 ++++-----
 ui/src/components/layout/StatusIndicator.tsx |   6 +-
 ui/src/config.ts                             |  10 +
 ui/src/pages/GroupPage.tsx                   |  24 +-
 ui/src/pages/RunnersPage.tsx                 |   8 +-
 ui/src/stores/uiStore.ts                     |  14 +
 11 files changed, 420 insertions(+), 250 deletions(-)

diff --git a/ui/public/config.json b/ui/public/config.json
index 2d8c690..fa023ba 100644
--- a/ui/public/config.json
+++ b/ui/public/config.json
@@ -1,3 +1,4 @@
 {
-  "apiUrl": "http://localhost:9090/api/v1"
+  "apiUrl": "http://localhost:9090/api/v1",
+  "benchmarkoorUrl": "https://benchmarkoor.core.ethpandaops.io"
 }
diff --git a/ui/src/components/jobs/JobCard.tsx b/ui/src/components/jobs/JobCard.tsx
index be71db5..332701d 100644
--- a/ui/src/components/jobs/JobCard.tsx
+++ b/ui/src/components/jobs/JobCard.tsx
@@ -1,10 +1,11 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, type ReactNode } from 'react';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
 import type { Job, JobTemplate } from '../../types';
 import { api } from '../../api/client';
 import { useAuthStore } from '../../stores/authStore';
 import { LabelsDisplay } from '../common/LabelBadge';
 import { JobDetailDialog } from './JobDetailDialog';
+import { buildBenchmarkoorRunUrl } from '../../config';
 
 interface JobCardProps {
   job: Job;
@@ -30,6 +31,7 @@ export function JobCard({ job, template, isDragging, dragHandleProps }: JobCardP
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showCancelConfirm, setShowCancelConfirm] = useState(false);
   const [showDetailDialog, setShowDetailDialog] = useState(false);
+  const [showActionsMenu, setShowActionsMenu] = useState(false);
   const [currentTime, setCurrentTime] = useState(() => Date.now());
 
   // Update current time every second for running jobs to show live duration
@@ -212,9 +214,154 @@ export function JobCard({ job, template, isDragging, dragHandleProps }: JobCardP
   const effectiveOwner = job.owner ?? template?.owner;
   const effectiveRepo = job.repo ?? template?.repo;
 
+  // Build the action list once, then render it two ways: inline icon buttons on
+  // wide cards (@md+), and a labelled overflow menu on narrow cards.
+  const isActive = job.status === 'triggered' || job.status === 'running';
+  const requeueBusy = toggleRequeueMutation.isPending || disableRequeueMutation.isPending;
+  const handleRequeueToggle = () => {
+    if (job.auto_requeue) {
+      setShowStopRequeueConfirm(true);
+    } else {
+      toggleRequeueMutation.mutate();
+    }
+  };
+  const benchmarkoorUrl = job.run_id ? buildBenchmarkoorRunUrl(job.run_id) : undefined;
+
+  type JobAction = {
+    key: string;
+    label: string;
+    icon: ReactNode;
+    onClick?: () => void;
+    href?: string;
+    disabled?: boolean;
+    tone?: 'default' | 'danger' | 'green' | 'purple';
+    active?: boolean;
+  };
+
+  const actions: JobAction[] = [
+    {
+      key: 'details',
+      label: 'View details',
+      onClick: () => setShowDetailDialog(true),
+      icon: (
+        
+          
+        
+      ),
+    },
+  ];
+  if (job.run_url) {
+    actions.push({
+      key: 'run',
+      label: 'View run on GitHub',
+      href: job.run_url,
+      icon: (
+        
+          
+          
+        
+      ),
+    });
+  }
+  if (benchmarkoorUrl) {
+    actions.push({
+      key: 'benchmarkoor',
+      label: 'View on Benchmarkoor',
+      href: benchmarkoorUrl,
+      icon: (
+        
+          
+        
+      ),
+    });
+  }
+  if (isAdmin && isActive) {
+    actions.push({
+      key: 'cancel',
+      label: 'Cancel workflow',
+      tone: 'danger',
+      disabled: cancelMutation.isPending,
+      onClick: () => setShowCancelConfirm(true),
+      icon: (
+        
+          
+        
+      ),
+    });
+  }
+  if (isAdmin && (isActive || job.status === 'pending')) {
+    actions.push({
+      key: 'requeue',
+      label: job.auto_requeue ? 'Disable auto-requeue' : 'Enable auto-requeue',
+      tone: 'purple',
+      active: job.auto_requeue,
+      disabled: requeueBusy,
+      onClick: handleRequeueToggle,
+      icon: (
+        
+          
+        
+      ),
+    });
+  }
+  if (isAdmin && job.status === 'pending' && !job.paused) {
+    actions.push({
+      key: 'pause',
+      label: 'Pause job',
+      disabled: pauseMutation.isPending,
+      onClick: () => pauseMutation.mutate(),
+      icon: (
+        
+          
+        
+      ),
+    });
+  }
+  if (isAdmin && job.status === 'pending' && job.paused) {
+    actions.push({
+      key: 'resume',
+      label: 'Resume job',
+      tone: 'green',
+      disabled: unpauseMutation.isPending,
+      onClick: () => unpauseMutation.mutate(),
+      icon: (
+        
+          
+        
+      ),
+    });
+  }
+  if (isAdmin && (job.status === 'pending' || job.status === 'failed')) {
+    actions.push({
+      key: 'delete',
+      label: 'Remove job',
+      tone: 'danger',
+      disabled: deleteMutation.isPending,
+      onClick: () => setShowDeleteConfirm(true),
+      icon: (
+        
+          
+        
+      ),
+    });
+  }
+
+  const inlineToneClass: Record, string> = {
+    default: 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300',
+    danger: 'text-zinc-500 hover:bg-red-500/10 hover:text-red-400',
+    green: 'text-zinc-500 hover:bg-green-500/10 hover:text-green-400',
+    purple: 'text-zinc-500 hover:bg-purple-500/10 hover:text-purple-400',
+  };
+  const menuToneClass: Record, string> = {
+    default: 'text-zinc-300 hover:bg-zinc-700',
+    danger: 'text-red-400 hover:bg-red-500/10',
+    green: 'text-green-400 hover:bg-green-500/10',
+    purple: 'text-purple-400 hover:bg-zinc-700',
+  };
+
   return (
     
@@ -233,7 +380,7 @@ export function JobCard({ job, template, isDragging, dragHandleProps }: JobCardP
{/* Header */} -
+
{job.paused ? 'paused' : job.status} @@ -260,7 +407,7 @@ export function JobCard({ job, template, isDragging, dragHandleProps }: JobCardP
{/* Job name */} -

+

{effectiveName}

@@ -279,133 +426,97 @@ export function JobCard({ job, template, isDragging, dragHandleProps }: JobCardP )}
- {/* Actions */} -
- {/* Info/details button */} - - {/* Cancel button for running/triggered jobs */} - {isAdmin && (job.status === 'triggered' || job.status === 'running') && ( - - )} - {/* Auto-requeue toggle for running/triggered jobs - appears before GitHub link */} - {isAdmin && (job.status === 'triggered' || job.status === 'running') && ( - - )} - {job.run_url && ( - - - - - - - )} - {isAdmin && job.status === 'pending' && !job.paused && ( - - )} - {isAdmin && job.status === 'pending' && job.paused && ( - - )} - {/* Auto-requeue toggle for pending jobs */} - {isAdmin && job.status === 'pending' && ( - - )} - {isAdmin && (job.status === 'pending' || job.status === 'failed') && ( + {/* Actions: inline icons on wide cards, overflow menu on narrow ones */} +
+ {/* Inline (wide card) */} + + + {/* Overflow menu (narrow card) */} +
- )} + {showActionsMenu && ( + <> +
setShowActionsMenu(false)} /> +
+ {actions.map((action) => { + const cls = `flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm ${ + action.active ? 'text-purple-400 hover:bg-zinc-700' : menuToneClass[action.tone ?? 'default'] + }`; + return action.href ? ( + setShowActionsMenu(false)} + className={cls} + > + {action.icon} + {action.label} + + ) : ( + + ); + })} +
+ + )} +
- {/* Bottom row: context left, timing right */} -
+ {/* Bottom row: context left, timing right (stacks when the card is narrow) */} +
{/* Context info - left */}
{(effectiveOwner && effectiveRepo) && ( @@ -426,7 +537,7 @@ export function JobCard({ job, template, isDragging, dragHandleProps }: JobCardP )}
{/* Timing info - right */} -
+
diff --git a/ui/src/components/jobs/JobDetailDialog.tsx b/ui/src/components/jobs/JobDetailDialog.tsx index b1e7551..64ee22d 100644 --- a/ui/src/components/jobs/JobDetailDialog.tsx +++ b/ui/src/components/jobs/JobDetailDialog.tsx @@ -4,6 +4,7 @@ import type { Job, JobTemplate } from '../../types'; import { api } from '../../api/client'; import { useAuthStore } from '../../stores/authStore'; import { LabelsDisplay } from '../common/LabelBadge'; +import { buildBenchmarkoorRunUrl } from '../../config'; interface JobDetailDialogProps { job: Job; @@ -221,20 +222,20 @@ export function JobDetailDialog({ job, template, isOpen, onClose }: JobDetailDia {/* Dialog */}
{/* Header */} -
-
- +
+
+ {job.paused ? 'paused' : job.status} -

+

{job.name ?? template?.name ?? job.template_id}

- #{job.position} + #{job.position}
-
+
{/* Owner */}
@@ -414,12 +415,28 @@ export function JobDetailDialog({ job, template, isOpen, onClose }: JobDetailDia
)} + {job.run_id && buildBenchmarkoorRunUrl(job.run_id) && ( + + )}
{/* Timing Info */}

Timing

-
+
Created

{formatDateTime(job.created_at)}

diff --git a/ui/src/components/layout/Header.tsx b/ui/src/components/layout/Header.tsx index b413f1d..beac421 100644 --- a/ui/src/components/layout/Header.tsx +++ b/ui/src/components/layout/Header.tsx @@ -5,7 +5,7 @@ import { useAuthStore } from '../../stores/authStore'; import { StatusIndicator } from './StatusIndicator'; export function Header() { - const { sidebarCollapsed, toggleSidebar } = useUIStore(); + const { sidebarCollapsed, toggleSidebar, toggleMobileNav } = useUIStore(); const { user, logout } = useAuthStore(); const [showUserMenu, setShowUserMenu] = useState(false); const menuRef = useRef(null); @@ -26,10 +26,22 @@ export function Header() { }; return ( -
+
+ {/* Mobile: open the overlay nav drawer. */} + + + {/* Desktop: collapse/expand the pinned sidebar. */} -
- - Dispatchoor - Dispatchoor +
+ + Dispatchoor + Dispatchoor
-
+
{/* System status indicator */} @@ -93,16 +105,16 @@ export function Header() { onClick={() => setShowUserMenu(!showUserMenu)} className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-zinc-300 hover:bg-zinc-800" > -
+
{user.username.charAt(0)}
- {user.username} + {user.username} {user.role === 'admin' && ( - + admin )} - +
{isFullBleed ? ( diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx index 43f274a..e08e6c5 100644 --- a/ui/src/components/layout/Sidebar.tsx +++ b/ui/src/components/layout/Sidebar.tsx @@ -3,102 +3,105 @@ import { useGroups } from '../../hooks/useGroups'; import { useUIStore } from '../../stores/uiStore'; export function Sidebar() { - const { sidebarCollapsed } = useUIStore(); + const { sidebarCollapsed, mobileNavOpen, setMobileNavOpen } = useUIStore(); const { data: groups, isLoading } = useGroups(); - if (sidebarCollapsed) { - return null; - } + // Close the mobile drawer after a navigation. Harmless on desktop (already closed). + const closeMobileNav = () => setMobileNavOpen(false); - return ( -
-

- Groups -

-
- {isLoading ? ( -
Loading...
- ) : groups && groups.length > 0 ? ( - groups.map((group) => ( - - `flex items-center justify-between rounded-sm px-3 py-2 text-sm transition-colors ${ - isActive - ? 'bg-blue-500/20 text-blue-400' - : 'text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100' - }` - } - > - {group.name} -
- {group.running_jobs > 0 && ( - - {group.running_jobs} - - )} - {group.queued_jobs > 0 && ( - - {group.queued_jobs} - - )} -
-
- )) - ) : ( -
No groups configured
- )} -
-
+ return ( + <> + {/* Backdrop: only on mobile while the drawer is open. */} + {mobileNavOpen && ( +