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 && ( +