void
}
-interface LineData {
- pathData: string
- color: string
- url: string | null
- key: string
-}
-
export function DataLines({
lines,
viewportXScale,
diff --git a/src/hypofuzz/frontend/src/pages/Workers.tsx b/src/hypofuzz/frontend/src/pages/Workers.tsx
index 4a6ae637..31cd9a70 100644
--- a/src/hypofuzz/frontend/src/pages/Workers.tsx
+++ b/src/hypofuzz/frontend/src/pages/Workers.tsx
@@ -31,21 +31,72 @@ class Worker {
}
}
+interface TimePeriod {
+ label: string
+ // duration in seconds
+ duration: number | null
+}
+
+const TIME_PERIODS: TimePeriod[] = [
+ { label: "Latest", duration: null },
+ { label: "1 hour", duration: 1 * 60 * 60 },
+ { label: "1 day", duration: 24 * 60 * 60 },
+ { label: "7 days", duration: 7 * 24 * 60 * 60 },
+ { label: "1 month", duration: 30 * 24 * 60 * 60 },
+ { label: "3 months", duration: 90 * 24 * 60 * 60 },
+]
+
function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000)
return date.toLocaleString()
}
-// 24 hours
-const DEFAULT_RANGE_DURATION = 24 * 60 * 60
+// tolerance for a region, in seconds
+const REGION_TOLERANCE = 5 * 60
+
+function segmentRegions(segments: Segment[]): [number, number][] {
+ // returns a list of [start, end] regions, where a region is defined as the largest
+ // interval where there is no timestamp without an active segment.
+ // so in
+ //
+ // ```
+ // [--] [-------]
+ // [---] [-] [------]
+ // [----] [--]
+ // ```
+ // there are 3 regions.
+
+ // We iterate over the egments in order of start time. We track the latest seen end time.
+ // If we ever see a segment with a later start time than the current end time, that means
+ // there must have been empty space between them, which marks a new region.
+
+ // assert segments are sorted by segment.start
+ console.assert(
+ segments.every(
+ (segment, index) => index === 0 || segment.start >= segments[index - 1].start,
+ ),
+ )
+
+ if (segments.length == 0) {
+ return []
+ }
-function niceDefaultRange(
- minTimestamp: number,
- maxTimestamp: number,
-): [number, number] {
- // by default: show from maxTimestamp at the end, to DEFAULT_RANGE_DURATION seconds before
- // that at the start.
- return [Math.max(minTimestamp, maxTimestamp - DEFAULT_RANGE_DURATION), maxTimestamp]
+ let regions: [number, number][] = []
+ let regionStart = segments[0].start
+ let latestEnd = segments[0].end
+ for (const segment of segments) {
+ if (segment.start > latestEnd + REGION_TOLERANCE) {
+ // this marks a new region
+ regions.push([regionStart, latestEnd])
+ regionStart = segment.start
+ }
+
+ latestEnd = Math.max(latestEnd, segment.end)
+ }
+
+ // finalize the current region
+ regions.push([regionStart, latestEnd])
+ return regions
}
function nodeColor(nodeid: string): string {
@@ -163,6 +214,8 @@ export function WorkersPage() {
const navigate = useNavigate()
const { showTooltip, hideTooltip, moveTooltip } = useTooltip()
const [expandedWorkers, setExpandedWorkers] = useState>(new Set())
+ const [selectedPeriod, setSelectedPeriod] = useState(TIME_PERIODS[0]) // Default to "Latest"
+ const [userRange, setUserRange] = useState<[number, number] | null>(null)
const workerUuids = OrderedSet(
Array.from(tests.values())
@@ -230,14 +283,65 @@ export function WorkersPage() {
})
workers.sortKey(worker => worker.segments[0].start)
-
- const [visibleRange, setVisibleRange] = useState<[number, number]>(
- niceDefaultRange(minTimestamp, maxTimestamp),
+ const segments = workers
+ .flatMap(worker => worker.segments)
+ .sortKey(segment => segment.start)
+ const regions = segmentRegions(segments)
+
+ const span = maxTimestamp - minTimestamp
+ // find the first time period which is larger than the span of the workers.
+ // that time period is available, but anything after is not.
+ const firstLargerPeriod = TIME_PERIODS.findIndex(
+ period => period.duration !== null && period.duration >= span,
)
+ function getSliderRange(): [number, number] {
+ if (selectedPeriod.duration === null) {
+ const latestRegion = regions[regions.length - 1]
+ // the range is just the last region, unless there are no segments, in which case
+ // we use the min/max timestamp
+ return regions.length > 0
+ ? [latestRegion[0], latestRegion[1]]
+ : [minTimestamp, maxTimestamp]
+ }
+
+ const range: [number, number] = [
+ Math.max(minTimestamp, maxTimestamp - selectedPeriod.duration!),
+ maxTimestamp,
+ ]
+
+ // trim the slider range to remove any time at the beginning or end when there
+ // are no active workers
+ let trimmedMin: number | null = null
+ let trimmedMax: number | null = null
+ for (const worker of workers) {
+ const visibleSegments = worker.visibleSegments(range)
+ if (visibleSegments.length === 0) {
+ continue
+ }
+
+ if (trimmedMin === null || visibleSegments[0].start < trimmedMin) {
+ trimmedMin = visibleSegments[0].start
+ }
+
+ if (
+ trimmedMax === null ||
+ visibleSegments[visibleSegments.length - 1].end > trimmedMax
+ ) {
+ trimmedMax = visibleSegments[visibleSegments.length - 1].end
+ }
+ }
+
+ return [trimmedMin ?? range[0], trimmedMax ?? range[1]]
+ }
+ const sliderRange = getSliderRange()
+ const visibleRange = userRange ?? sliderRange
+
useEffect(() => {
- setVisibleRange(niceDefaultRange(minTimestamp, maxTimestamp))
- }, [minTimestamp, maxTimestamp])
+ // reset the range when clicking on a period, even if it's the same period. This gives a
+ // nice "reset button" ux to users.
+ setUserRange(null)
+ }, [selectedPeriod])
const [visibleMin, visibleMax] = visibleRange
const visibleDuration = visibleMax - visibleMin
@@ -287,12 +391,30 @@ export function WorkersPage() {
+
+ {TIME_PERIODS.map((period, index) => {
+ const available = index <= firstLargerPeriod
+ return (
+
available && setSelectedPeriod(period)}
+ >
+ {period.label}
+
+ )
+ })}
+
setVisibleRange(newRange)}
- step={(maxTimestamp - minTimestamp) / 1000}
+ onChange={newRange => setUserRange(newRange)}
+ step={(sliderRange[1] - sliderRange[0]) / 1000}
/>
diff --git a/src/hypofuzz/frontend/src/styles/styles.scss b/src/hypofuzz/frontend/src/styles/styles.scss
index 5d25569e..1ef1f2a2 100644
--- a/src/hypofuzz/frontend/src/styles/styles.scss
+++ b/src/hypofuzz/frontend/src/styles/styles.scss
@@ -1340,6 +1340,51 @@ pre code.hljs {
}
}
+ &__durations {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 15px;
+ flex-wrap: wrap;
+
+ &__button {
+ background: white;
+ color: #666;
+
+ padding: 4px 7px;
+ border: 1px solid #d1d9e0;
+ border-radius: 4px;
+
+ cursor: pointer;
+ font-size: 0.9rem;
+ user-select: none;
+ transition: all 0.05s ease;
+
+ &:hover:not(&--disabled):not(&--active) {
+ background: #f8f9fa;
+ color: #495057;
+ }
+
+ &--active {
+ background: $color-primary;
+ border-color: $color-primary;
+ color: white;
+ font-weight: 600;
+
+ &:hover {
+ background: $color-primary-hover;
+ }
+ }
+
+ &--disabled {
+ background: #f8f9fa;
+ border-color: #e9ecef;
+ color: #adb5bd;
+ cursor: not-allowed;
+ opacity: 0.6;
+ }
+ }
+ }
+
&__timeline-header {
display: flex;
justify-content: space-between;
diff --git a/src/hypofuzz/frontend/src/styles/theme.scss b/src/hypofuzz/frontend/src/styles/theme.scss
index cda40ab3..2302a9f1 100644
--- a/src/hypofuzz/frontend/src/styles/theme.scss
+++ b/src/hypofuzz/frontend/src/styles/theme.scss
@@ -3,6 +3,8 @@ $primary-s: 75%;
$primary-l: 30%;
$color-primary: hsl($primary-h, $primary-s, $primary-l);
+$color-primary-hover: hsl($primary-h, $primary-s, 40%);
+
$secondary-h: 24;
$secondary-s: 80%;
$secondary-l: 60%;
diff --git a/tests/test_collection.py b/tests/test_collection.py
index 238eb323..42954712 100644
--- a/tests/test_collection.py
+++ b/tests/test_collection.py
@@ -274,3 +274,13 @@ def test_a(n):
pass
"""
assert not collect_names(code)
+
+
+def test_skips_derandomize():
+ code = """
+ @given(st.integers())
+ @settings(derandomize=True)
+ def test_a(n):
+ pass
+ """
+ assert not collect_names(code)
diff --git a/visual_tests/test_collection.py b/visual_tests/test_collection.py
index 8fdaf8c5..bea32a3b 100644
--- a/visual_tests/test_collection.py
+++ b/visual_tests/test_collection.py
@@ -39,4 +39,10 @@ def test_no_generate_phase(n):
pass
+@settings(derandomize=True)
+@given(st.integers())
+def test_sets_derandomize(n):
+ pass
+
+
# TODO: visual tests for _skip_because("error") and _skip_because("not_a_function")