+
{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
)}
- {/* 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
-
+
{/* 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
+ )}
+
+
+
+
+
linkClass(isActive)}>
+
+
+
+ All Runners
+
+
+
+
+ >
);
}
diff --git a/ui/src/components/layout/StatusIndicator.tsx b/ui/src/components/layout/StatusIndicator.tsx
index d32cb33..d7d0bdb 100644
--- a/ui/src/components/layout/StatusIndicator.tsx
+++ b/ui/src/components/layout/StatusIndicator.tsx
@@ -81,9 +81,9 @@ export function StatusIndicator() {
onClick={() => setShowDropdown(!showDropdown)}
className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-300"
>
-
- {statusLabel}
-
+
+ {statusLabel}
+
diff --git a/ui/src/config.ts b/ui/src/config.ts
index dfc391c..5dacf9b 100644
--- a/ui/src/config.ts
+++ b/ui/src/config.ts
@@ -1,11 +1,21 @@
export interface AppConfig {
apiUrl: string;
+ // Base URL for Benchmarkoor (e.g. https://benchmarkoor.core.ethpandaops.io).
+ // When set, jobs with a GitHub run_id display a link to the corresponding
+ // benchmark run, filtered via the github.run_id metadata label.
+ benchmarkoorUrl?: string;
}
const defaultConfig: AppConfig = {
apiUrl: '/api/v1',
};
+export function buildBenchmarkoorRunUrl(runId: number | string): string | undefined {
+ const base = config.benchmarkoorUrl?.replace(/\/+$/, '');
+ if (!base) return undefined;
+ return `${base}/runs?labels=${encodeURIComponent('github.run_id')}:${encodeURIComponent(String(runId))}`;
+}
+
let config: AppConfig = defaultConfig;
let configLoaded = false;
let configPromise: Promise | null = null;
diff --git a/ui/src/pages/GroupPage.tsx b/ui/src/pages/GroupPage.tsx
index 12ce4af..247addc 100644
--- a/ui/src/pages/GroupPage.tsx
+++ b/ui/src/pages/GroupPage.tsx
@@ -863,7 +863,7 @@ export function GroupPage() {
return (
{/* Header */}
-
+
{group.name}
@@ -888,7 +888,7 @@ export function GroupPage() {
{isAdmin && (
-
+
{group.paused ? (