Skip to content

Commit 605c551

Browse files
authored
Merge pull request #212 from Zac-HD/next
Better worker view ux
2 parents 4340868 + 5623ab4 commit 605c551

File tree

8 files changed

+219
-27
lines changed

8 files changed

+219
-27
lines changed

src/hypofuzz/collection.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ def pytest_collection_finish(self, session: pytest.Session) -> None:
106106
test_settings = getattr(
107107
item.obj, "_hypothesis_internal_use_settings", settings()
108108
)
109+
110+
# derandomize=True implies database=None, so this will be skipped by our
111+
# differing_database check below anyway, but we can give a less confusing
112+
# skip reason by checking for derandomize explicitly.
113+
if test_settings.derandomize:
114+
self._skip_because("sets_derandomize", item.nodeid)
115+
continue
116+
109117
if (test_database := test_settings.database) != settings().database:
110118
self._skip_because(
111119
"differing_database",

src/hypofuzz/frontend/src/components/RangeSlider.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function RangeSlider({ min, max, value, onChange, step = 1 }: RangeSlider
6060
setDragging(null)
6161
}
6262

63-
const handleTrackClick = (event: React.MouseEvent) => {
63+
const handleTrackMouseDown = (event: React.MouseEvent) => {
6464
if (dragging) return
6565

6666
const newValue = getValueFromPosition(event.clientX)
@@ -69,8 +69,10 @@ export function RangeSlider({ min, max, value, onChange, step = 1 }: RangeSlider
6969

7070
if (minDistance < maxDistance) {
7171
onChange([Math.min(newValue, maxValue), maxValue])
72+
setDragging("min")
7273
} else {
7374
onChange([minValue, Math.max(newValue, minValue)])
75+
setDragging("max")
7476
}
7577
}
7678

@@ -89,7 +91,11 @@ export function RangeSlider({ min, max, value, onChange, step = 1 }: RangeSlider
8991
return (
9092
<div className="range-slider">
9193
<div className="range-slider__container">
92-
<div ref={sliderRef} className="range-slider__track" onClick={handleTrackClick}>
94+
<div
95+
ref={sliderRef}
96+
className="range-slider__track"
97+
onMouseDown={handleTrackMouseDown}
98+
>
9399
<div
94100
className="range-slider__range"
95101
style={{

src/hypofuzz/frontend/src/components/graph/DataLines.tsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,6 @@ interface DataLinesProps {
1515
navigate: (path: string) => void
1616
}
1717

18-
interface LineData {
19-
pathData: string
20-
color: string
21-
url: string | null
22-
key: string
23-
}
24-
2518
export function DataLines({
2619
lines,
2720
viewportXScale,

src/hypofuzz/frontend/src/pages/Workers.tsx

Lines changed: 140 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,72 @@ class Worker {
3131
}
3232
}
3333

34+
interface TimePeriod {
35+
label: string
36+
// duration in seconds
37+
duration: number | null
38+
}
39+
40+
const TIME_PERIODS: TimePeriod[] = [
41+
{ label: "Latest", duration: null },
42+
{ label: "1 hour", duration: 1 * 60 * 60 },
43+
{ label: "1 day", duration: 24 * 60 * 60 },
44+
{ label: "7 days", duration: 7 * 24 * 60 * 60 },
45+
{ label: "1 month", duration: 30 * 24 * 60 * 60 },
46+
{ label: "3 months", duration: 90 * 24 * 60 * 60 },
47+
]
48+
3449
function formatTimestamp(timestamp: number): string {
3550
const date = new Date(timestamp * 1000)
3651
return date.toLocaleString()
3752
}
3853

39-
// 24 hours
40-
const DEFAULT_RANGE_DURATION = 24 * 60 * 60
54+
// tolerance for a region, in seconds
55+
const REGION_TOLERANCE = 5 * 60
56+
57+
function segmentRegions(segments: Segment[]): [number, number][] {
58+
// returns a list of [start, end] regions, where a region is defined as the largest
59+
// interval where there is no timestamp without an active segment.
60+
// so in
61+
//
62+
// ```
63+
// [--] [-------]
64+
// [---] [-] [------]
65+
// [----] [--]
66+
// ```
67+
// there are 3 regions.
68+
69+
// We iterate over the egments in order of start time. We track the latest seen end time.
70+
// If we ever see a segment with a later start time than the current end time, that means
71+
// there must have been empty space between them, which marks a new region.
72+
73+
// assert segments are sorted by segment.start
74+
console.assert(
75+
segments.every(
76+
(segment, index) => index === 0 || segment.start >= segments[index - 1].start,
77+
),
78+
)
79+
80+
if (segments.length == 0) {
81+
return []
82+
}
4183

42-
function niceDefaultRange(
43-
minTimestamp: number,
44-
maxTimestamp: number,
45-
): [number, number] {
46-
// by default: show from maxTimestamp at the end, to DEFAULT_RANGE_DURATION seconds before
47-
// that at the start.
48-
return [Math.max(minTimestamp, maxTimestamp - DEFAULT_RANGE_DURATION), maxTimestamp]
84+
let regions: [number, number][] = []
85+
let regionStart = segments[0].start
86+
let latestEnd = segments[0].end
87+
for (const segment of segments) {
88+
if (segment.start > latestEnd + REGION_TOLERANCE) {
89+
// this marks a new region
90+
regions.push([regionStart, latestEnd])
91+
regionStart = segment.start
92+
}
93+
94+
latestEnd = Math.max(latestEnd, segment.end)
95+
}
96+
97+
// finalize the current region
98+
regions.push([regionStart, latestEnd])
99+
return regions
49100
}
50101

51102
function nodeColor(nodeid: string): string {
@@ -163,6 +214,8 @@ export function WorkersPage() {
163214
const navigate = useNavigate()
164215
const { showTooltip, hideTooltip, moveTooltip } = useTooltip()
165216
const [expandedWorkers, setExpandedWorkers] = useState<Set<string>>(new Set())
217+
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(TIME_PERIODS[0]) // Default to "Latest"
218+
const [userRange, setUserRange] = useState<[number, number] | null>(null)
166219

167220
const workerUuids = OrderedSet(
168221
Array.from(tests.values())
@@ -230,14 +283,65 @@ export function WorkersPage() {
230283
})
231284

232285
workers.sortKey(worker => worker.segments[0].start)
233-
234-
const [visibleRange, setVisibleRange] = useState<[number, number]>(
235-
niceDefaultRange(minTimestamp, maxTimestamp),
286+
const segments = workers
287+
.flatMap(worker => worker.segments)
288+
.sortKey(segment => segment.start)
289+
const regions = segmentRegions(segments)
290+
291+
const span = maxTimestamp - minTimestamp
292+
// find the first time period which is larger than the span of the workers.
293+
// that time period is available, but anything after is not.
294+
const firstLargerPeriod = TIME_PERIODS.findIndex(
295+
period => period.duration !== null && period.duration >= span,
236296
)
237297

298+
function getSliderRange(): [number, number] {
299+
if (selectedPeriod.duration === null) {
300+
const latestRegion = regions[regions.length - 1]
301+
// the range is just the last region, unless there are no segments, in which case
302+
// we use the min/max timestamp
303+
return regions.length > 0
304+
? [latestRegion[0], latestRegion[1]]
305+
: [minTimestamp, maxTimestamp]
306+
}
307+
308+
const range: [number, number] = [
309+
Math.max(minTimestamp, maxTimestamp - selectedPeriod.duration!),
310+
maxTimestamp,
311+
]
312+
313+
// trim the slider range to remove any time at the beginning or end when there
314+
// are no active workers
315+
let trimmedMin: number | null = null
316+
let trimmedMax: number | null = null
317+
for (const worker of workers) {
318+
const visibleSegments = worker.visibleSegments(range)
319+
if (visibleSegments.length === 0) {
320+
continue
321+
}
322+
323+
if (trimmedMin === null || visibleSegments[0].start < trimmedMin) {
324+
trimmedMin = visibleSegments[0].start
325+
}
326+
327+
if (
328+
trimmedMax === null ||
329+
visibleSegments[visibleSegments.length - 1].end > trimmedMax
330+
) {
331+
trimmedMax = visibleSegments[visibleSegments.length - 1].end
332+
}
333+
}
334+
335+
return [trimmedMin ?? range[0], trimmedMax ?? range[1]]
336+
}
337+
const sliderRange = getSliderRange()
338+
const visibleRange = userRange ?? sliderRange
339+
238340
useEffect(() => {
239-
setVisibleRange(niceDefaultRange(minTimestamp, maxTimestamp))
240-
}, [minTimestamp, maxTimestamp])
341+
// reset the range when clicking on a period, even if it's the same period. This gives a
342+
// nice "reset button" ux to users.
343+
setUserRange(null)
344+
}, [selectedPeriod])
241345

242346
const [visibleMin, visibleMax] = visibleRange
243347
const visibleDuration = visibleMax - visibleMin
@@ -287,12 +391,30 @@ export function WorkersPage() {
287391
</div>
288392
<div className="workers">
289393
<div className="workers__controls">
394+
<div className="workers__durations">
395+
{TIME_PERIODS.map((period, index) => {
396+
const available = index <= firstLargerPeriod
397+
return (
398+
<div
399+
key={index}
400+
className={`workers__durations__button ${
401+
selectedPeriod.label === period.label
402+
? "workers__durations__button--active"
403+
: ""
404+
} ${!available ? "workers__durations__button--disabled" : ""}`}
405+
onClick={() => available && setSelectedPeriod(period)}
406+
>
407+
{period.label}
408+
</div>
409+
)
410+
})}
411+
</div>
290412
<RangeSlider
291-
min={minTimestamp}
292-
max={maxTimestamp}
413+
min={sliderRange[0]}
414+
max={sliderRange[1]}
293415
value={visibleRange}
294-
onChange={newRange => setVisibleRange(newRange)}
295-
step={(maxTimestamp - minTimestamp) / 1000}
416+
onChange={newRange => setUserRange(newRange)}
417+
step={(sliderRange[1] - sliderRange[0]) / 1000}
296418
/>
297419
</div>
298420
<div className="workers__timeline-header">

src/hypofuzz/frontend/src/styles/styles.scss

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,6 +1340,51 @@ pre code.hljs {
13401340
}
13411341
}
13421342

1343+
&__durations {
1344+
display: flex;
1345+
gap: 6px;
1346+
margin-bottom: 15px;
1347+
flex-wrap: wrap;
1348+
1349+
&__button {
1350+
background: white;
1351+
color: #666;
1352+
1353+
padding: 4px 7px;
1354+
border: 1px solid #d1d9e0;
1355+
border-radius: 4px;
1356+
1357+
cursor: pointer;
1358+
font-size: 0.9rem;
1359+
user-select: none;
1360+
transition: all 0.05s ease;
1361+
1362+
&:hover:not(&--disabled):not(&--active) {
1363+
background: #f8f9fa;
1364+
color: #495057;
1365+
}
1366+
1367+
&--active {
1368+
background: $color-primary;
1369+
border-color: $color-primary;
1370+
color: white;
1371+
font-weight: 600;
1372+
1373+
&:hover {
1374+
background: $color-primary-hover;
1375+
}
1376+
}
1377+
1378+
&--disabled {
1379+
background: #f8f9fa;
1380+
border-color: #e9ecef;
1381+
color: #adb5bd;
1382+
cursor: not-allowed;
1383+
opacity: 0.6;
1384+
}
1385+
}
1386+
}
1387+
13431388
&__timeline-header {
13441389
display: flex;
13451390
justify-content: space-between;

src/hypofuzz/frontend/src/styles/theme.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ $primary-s: 75%;
33
$primary-l: 30%;
44
$color-primary: hsl($primary-h, $primary-s, $primary-l);
55

6+
$color-primary-hover: hsl($primary-h, $primary-s, 40%);
7+
68
$secondary-h: 24;
79
$secondary-s: 80%;
810
$secondary-l: 60%;

tests/test_collection.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,13 @@ def test_a(n):
274274
pass
275275
"""
276276
assert not collect_names(code)
277+
278+
279+
def test_skips_derandomize():
280+
code = """
281+
@given(st.integers())
282+
@settings(derandomize=True)
283+
def test_a(n):
284+
pass
285+
"""
286+
assert not collect_names(code)

visual_tests/test_collection.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,10 @@ def test_no_generate_phase(n):
3939
pass
4040

4141

42+
@settings(derandomize=True)
43+
@given(st.integers())
44+
def test_sets_derandomize(n):
45+
pass
46+
47+
4248
# TODO: visual tests for _skip_because("error") and _skip_because("not_a_function")

0 commit comments

Comments
 (0)