Skip to content

Commit 3fa4af3

Browse files
author
Andrea Ceriani
committed
Job details view changes and finalization
1 parent 032594b commit 3fa4af3

File tree

3 files changed

+362
-378
lines changed

3 files changed

+362
-378
lines changed
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
/*************************************************************************
2+
Copyright (c) 2025, ETH Zurich. All rights reserved.
3+
4+
Please, refer to the LICENSE file in the root directory.
5+
SPDX-License-Identifier: BSD-3-Clause
6+
*************************************************************************/
7+
import React, { useEffect, useState, useRef, useMemo } from 'react'
8+
9+
interface EmbedPanelGrafanaProps {
10+
baseUrl: string
11+
jobId: string | number
12+
cluster: string
13+
nodes: string[]
14+
jobStartMs: number
15+
jobEndMs: number
16+
panelId?: number
17+
orgId?: string | number
18+
refresh?: string
19+
width?: number | string
20+
height?: number | string
21+
initialMinutes?: number
22+
}
23+
24+
function buildSrc({
25+
baseUrl,
26+
jobId,
27+
cluster,
28+
nodes,
29+
orgId,
30+
panelId,
31+
refresh,
32+
fromMs,
33+
toMs,
34+
}: {
35+
baseUrl: string
36+
jobId: string | number
37+
cluster: string
38+
nodes: string[]
39+
orgId: string | number
40+
panelId: number
41+
refresh: string
42+
fromMs: number
43+
toMs: number
44+
}) {
45+
const u = new URL(
46+
baseUrl,
47+
typeof window !== 'undefined' ? window.location.origin : 'http://localhost',
48+
)
49+
const toStrip = [
50+
'from',
51+
'to',
52+
'timezone',
53+
'refresh',
54+
'orgId',
55+
'panelId',
56+
'jobId',
57+
'var-nid',
58+
'var-vcluster',
59+
'__feature.dashboardSceneSolo',
60+
]
61+
toStrip.forEach((k) => u.searchParams.delete(k))
62+
u.searchParams.set('orgId', String(orgId))
63+
u.searchParams.set('panelId', String(panelId))
64+
u.searchParams.set('jobId', String(jobId))
65+
u.searchParams.set('var-vcluster', cluster)
66+
nodes.forEach((node) => {
67+
u.searchParams.append('var-nid', node)
68+
})
69+
u.searchParams.set('refresh', refresh)
70+
u.searchParams.set('timezone', 'browser')
71+
u.searchParams.set('from', String(fromMs)) // epoch ms
72+
u.searchParams.set('to', String(toMs))
73+
u.searchParams.set('__feature.dashboardSceneSolo', 'true')
74+
const full = u.toString()
75+
return full
76+
}
77+
78+
/** Convert a Date to a value acceptable by <input type="datetime-local"> */
79+
function dateToLocalInput(d: Date): string {
80+
// toISOString gives Z (UTC). datetime-local expects local without timezone.
81+
// Build manually using local parts and zero-pad.
82+
const pad = (n: number) => String(n).padStart(2, '0')
83+
const yyyy = d.getFullYear()
84+
const MM = pad(d.getMonth() + 1)
85+
const dd = pad(d.getDate())
86+
const hh = pad(d.getHours())
87+
const mm = pad(d.getMinutes())
88+
const ss = pad(d.getSeconds())
89+
return `${yyyy}-${MM}-${dd}T${hh}:${mm}:${ss}`
90+
}
91+
92+
/** Parse the <input type="datetime-local"> string (local time) back to a Date */
93+
function localInputToDate(v: string): Date {
94+
// v like "2025-09-01T17:00"
95+
const [datePart, timePart] = v.split('T')
96+
const [y, m, d] = datePart.split('-').map((n) => parseInt(n, 10))
97+
const [hh, mm, ss] = timePart.split(':').map((n) => parseInt(n, 10))
98+
return new Date(y, m - 1, d, hh, mm, ss || 0, 0) // local time
99+
}
100+
101+
interface QuickButtonProps {
102+
onClick: () => void
103+
disabled?: boolean
104+
children: React.ReactNode
105+
}
106+
107+
const QuickButton: React.FC<QuickButtonProps> = ({ onClick, disabled = false, children }) => {
108+
return (
109+
<button
110+
type='button'
111+
onClick={onClick}
112+
disabled={disabled}
113+
className={[
114+
'rounded-xl bg-gray-50 px-3 py-2 text-sm font-medium border shadow-sm',
115+
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-100 cursor-pointer',
116+
].join(' ')}
117+
>
118+
{children}
119+
</button>
120+
)
121+
}
122+
123+
const EmbedPanelGrafana: React.FC<EmbedPanelGrafanaProps> = ({
124+
baseUrl,
125+
jobId,
126+
cluster,
127+
nodes,
128+
jobStartMs,
129+
jobEndMs,
130+
panelId = 2,
131+
orgId = 1,
132+
refresh = '5s',
133+
width = '100%',
134+
height = 600,
135+
initialMinutes = 5,
136+
}) => {
137+
const [minMs, maxMs] = useMemo(() => {
138+
const min = Number.isFinite(jobStartMs) ? jobStartMs : Date.now()
139+
const rawEnd = Number.isFinite(jobEndMs) ? jobEndMs : Date.now()
140+
let end = Math.max(rawEnd, min)
141+
if (end === min) end = min + 1_000
142+
return [min, end] as const
143+
}, [jobStartMs, jobEndMs])
144+
145+
const durationMs = maxMs - minMs
146+
const [from, setFrom] = useState<Date>(() => new Date(minMs))
147+
const [to, setTo] = useState<Date>(() => new Date(maxMs))
148+
const [error, setError] = useState<string | null>(null)
149+
const [appliedSrc, setAppliedSrc] = useState<string>(() =>
150+
buildSrc({
151+
baseUrl,
152+
jobId,
153+
cluster,
154+
nodes,
155+
orgId,
156+
panelId,
157+
refresh,
158+
fromMs: minMs,
159+
toMs: maxMs,
160+
}),
161+
)
162+
163+
const clampMs = (t: number) => Math.min(Math.max(t, minMs), maxMs)
164+
const clampDate = (d: Date) => new Date(clampMs(d.getTime()))
165+
166+
useEffect(() => {
167+
const f = new Date(minMs)
168+
const t = new Date(maxMs)
169+
setFrom(f)
170+
setTo(t)
171+
setAppliedSrc(
172+
buildSrc({
173+
baseUrl,
174+
jobId,
175+
cluster,
176+
nodes,
177+
orgId,
178+
panelId,
179+
refresh,
180+
fromMs: minMs,
181+
toMs: maxMs,
182+
}),
183+
)
184+
setError(null)
185+
}, [minMs, maxMs, baseUrl, jobId, orgId, panelId, refresh])
186+
187+
const presets = [
188+
{ label: 'Last 5m', minutes: 5 },
189+
{ label: 'Last 15m', minutes: 15 },
190+
{ label: 'Last 1h', minutes: 60 },
191+
{ label: 'Last 6h', minutes: 360 },
192+
{ label: 'Last 24h', minutes: 1440 },
193+
]
194+
195+
function handleQuick(minutes: number) {
196+
const toT = new Date(clampMs(maxMs))
197+
const fromT = new Date(clampMs(maxMs - minutes * 60_000))
198+
setFrom(fromT)
199+
setTo(toT)
200+
setError(null)
201+
}
202+
203+
function handleApply() {
204+
const f = clampDate(from)
205+
const t = clampDate(to)
206+
if (f.getTime() >= t.getTime()) {
207+
setError("'From' must be earlier than 'To'.")
208+
return
209+
}
210+
setError(null)
211+
setAppliedSrc(
212+
buildSrc({
213+
baseUrl,
214+
jobId,
215+
cluster,
216+
nodes,
217+
orgId,
218+
panelId,
219+
refresh,
220+
fromMs: f.getTime(),
221+
toMs: t.getTime(),
222+
}),
223+
)
224+
}
225+
226+
const minInput = dateToLocalInput(new Date(minMs))
227+
const maxInput = dateToLocalInput(new Date(maxMs))
228+
229+
const key = appliedSrc // changing key forces iframe remount (hard reload)
230+
231+
return (
232+
<div className='w-full'>
233+
{/* Controls */}
234+
<div className='pb-4'>
235+
<div className='flex flex-col gap-3 md:flex-row md:items-end md:justify-between'>
236+
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
237+
<label className='flex flex-col text-sm'>
238+
<span className='mb-1 font-medium'>From</span>
239+
<input
240+
type='datetime-local'
241+
step='1'
242+
className='border px-3 py-2 shadow-sm focus:outline-none focus:ring-2'
243+
value={dateToLocalInput(from)}
244+
min={minInput}
245+
max={maxInput}
246+
onChange={(e) => setFrom(clampDate(localInputToDate(e.target.value)))}
247+
/>
248+
</label>
249+
<label className='flex flex-col text-sm'>
250+
<span className='mb-1 font-medium'>To</span>
251+
<input
252+
type='datetime-local'
253+
step='1'
254+
className='border px-3 py-2 shadow-sm focus:outline-none focus:ring-2'
255+
value={dateToLocalInput(to)}
256+
min={minInput}
257+
max={maxInput}
258+
onChange={(e) => setTo(clampDate(localInputToDate(e.target.value)))}
259+
/>
260+
</label>
261+
</div>
262+
263+
<div className='flex flex-wrap gap-2'>
264+
{presets.map((p) => {
265+
const disabled = durationMs < p.minutes * 60_000
266+
return (
267+
<QuickButton
268+
key={p.minutes}
269+
onClick={() => handleQuick(p.minutes)}
270+
disabled={disabled}
271+
>
272+
{p.label}
273+
</QuickButton>
274+
)
275+
})}
276+
<button
277+
className='border rounded-xl px-4 py-2 font-medium shadow-sm hover:shadow transition'
278+
onClick={handleApply}
279+
title='Reload the iframe with the selected time range'
280+
>
281+
Apply
282+
</button>
283+
</div>
284+
</div>
285+
{error && <div className='text-sm text-red-600'>{error}</div>}
286+
</div>
287+
{/* Iframe */}
288+
<div className='overflow-hidden border'>
289+
<iframe
290+
title={`Grafana panel ${panelId}`}
291+
key={key}
292+
src={appliedSrc}
293+
// width={typeof width === 'number' ? String(width) : width}
294+
// height={typeof height === 'number' ? String(height) : height}
295+
// style={{ border: 0, width: typeof width === 'number' ? `${width}px` : width }}
296+
loading='lazy'
297+
referrerPolicy='no-referrer'
298+
className='w-full h-[600px] border-0'
299+
/>
300+
</div>
301+
</div>
302+
)
303+
}
304+
305+
export default EmbedPanelGrafana

0 commit comments

Comments
 (0)