Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions frontend/app/(app)/dashboard/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,38 @@ describe("DashboardPage", () => {
expect(timeout).toHaveValue(8);
});

it("lets the user type period and grace when creating a heartbeat check", async () => {
renderDashboard("SIGNAL");

fireEvent.click(
(await screen.findAllByRole("button", { name: /new check/i }))[0],
);

const period = await screen.findByLabelText("Period");
const grace = screen.getByLabelText("Grace");
expect(period).toHaveValue(300);
expect(grace).toHaveValue(60);

fireEvent.change(period, { target: { value: "600" } });
fireEvent.change(grace, { target: { value: "120" } });
expect(period).toHaveValue(600);
expect(grace).toHaveValue(120);
});

it("lets the user type an interval when creating an HTTP check", async () => {
renderDashboard("SIGNAL");

fireEvent.click(
(await screen.findAllByRole("button", { name: /new check/i }))[0],
);
fireEvent.click(await screen.findByRole("button", { name: /^http$/i }));

const interval = await screen.findByLabelText("Interval");
expect(interval).toHaveValue(60);
fireEvent.change(interval, { target: { value: "180" } });
expect(interval).toHaveValue(180);
});

it("uses the SIGNAL creator floor when a SOLO collaborator creates a check", async () => {
context.activeOrg.plan = "SIGNAL";
renderDashboard("SOLO");
Expand All @@ -297,7 +329,7 @@ describe("DashboardPage", () => {
fireEvent.click(newCheckButtons[0]);

await waitFor(() =>
expect(document.getElementById("check-period")).toHaveAttribute("data-min", "60")
expect(screen.getByLabelText("Period")).toHaveAttribute("min", "60")
);
});

Expand All @@ -311,7 +343,7 @@ describe("DashboardPage", () => {
fireEvent.click(newCheckButtons[0]);

await waitFor(() =>
expect(document.getElementById("check-period")).toHaveAttribute("data-min", "300")
expect(screen.getByLabelText("Period")).toHaveAttribute("min", "300")
);
});

Expand Down
74 changes: 36 additions & 38 deletions frontend/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { CircleGauge, Plus, Pause, Play } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { DurationSliderField } from "@/components/app/duration-slider-field";
import { formatDuration } from "@/lib/format";
import { usePollWhenVisible } from "@/lib/use-poll-when-visible";
import { CHECK_POLL_INTERVAL_MS } from "@/lib/polling";
import { planIntervalFloor } from "@/lib/plan-limits";
Expand Down Expand Up @@ -361,24 +359,24 @@ function CreateCheckDialog({

{scheduleType === "simple" ? (
<>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="check-period">Period</Label>
<span className="text-sm text-muted-foreground font-mono">{formatDuration(parseInt(periodSeconds || "0", 10))}</span>
</div>
<Slider id="check-period" min={floor} max={86400} step={1}
value={[parseInt(periodSeconds || String(floor), 10)]}
onValueChange={(v) => setPeriodSeconds(String(Array.isArray(v) ? v[0] : v))} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="check-grace">Grace</Label>
<span className="text-sm text-muted-foreground font-mono">{formatDuration(parseInt(graceSeconds || "0", 10))}</span>
</div>
<Slider id="check-grace" min={0} max={3600} step={1}
value={[parseInt(graceSeconds || "0", 10)]}
onValueChange={(v) => setGraceSeconds(String(Array.isArray(v) ? v[0] : v))} />
</div>
<DurationSliderField
id="check-period"
label="Period"
valueMs={parseInt(periodSeconds || String(floor), 10) * 1000}
onValueMsChange={(ms) => setPeriodSeconds(String(Math.round(ms / 1000)))}
minMs={floor * 1000}
maxMs={86400 * 1000}
stepMs={1000}
/>
<DurationSliderField
id="check-grace"
label="Grace"
valueMs={parseInt(graceSeconds || "0", 10) * 1000}
onValueMsChange={(ms) => setGraceSeconds(String(Math.round(ms / 1000)))}
minMs={0}
maxMs={3600 * 1000}
stepMs={1000}
/>
</>
) : (
<>
Expand Down Expand Up @@ -408,15 +406,15 @@ function CreateCheckDialog({
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="check-grace">Grace</Label>
<span className="text-sm text-muted-foreground font-mono">{formatDuration(parseInt(graceSeconds || "0", 10))}</span>
</div>
<Slider id="check-grace" min={0} max={3600} step={1}
value={[parseInt(graceSeconds || "0", 10)]}
onValueChange={(v) => setGraceSeconds(String(Array.isArray(v) ? v[0] : v))} />
</div>
<DurationSliderField
id="check-grace"
label="Grace"
valueMs={parseInt(graceSeconds || "0", 10) * 1000}
onValueMsChange={(ms) => setGraceSeconds(String(Math.round(ms / 1000)))}
minMs={0}
maxMs={3600 * 1000}
stepMs={1000}
/>
{cronValid ? (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Next runs:</p>
Expand Down Expand Up @@ -452,15 +450,15 @@ function CreateCheckDialog({
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="check-interval">Interval</Label>
<span className="text-sm text-muted-foreground font-mono">{formatDuration(parseInt(intervalSeconds || "0", 10))}</span>
</div>
<Slider id="check-interval" min={floor} max={3600} step={1}
value={[parseInt(intervalSeconds || String(floor), 10)]}
onValueChange={(v) => setIntervalSeconds(String(Array.isArray(v) ? v[0] : v))} />
</div>
<DurationSliderField
id="check-interval"
label="Interval"
valueMs={parseInt(intervalSeconds || String(floor), 10) * 1000}
onValueMsChange={(ms) => setIntervalSeconds(String(Math.round(ms / 1000)))}
minMs={floor * 1000}
maxMs={3600 * 1000}
stepMs={1000}
/>
<DurationSliderField
id="check-timeout"
label="Timeout"
Expand Down
35 changes: 31 additions & 4 deletions frontend/components/app/duration-slider-field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { DurationSliderField } from "./duration-slider-field";

function Harness({ initialMs = 5000 }: { initialMs?: number }) {
function Harness({
initialMs = 5000,
minMs = 1000,
maxMs = 60000,
stepMs = 500,
}: {
initialMs?: number;
minMs?: number;
maxMs?: number;
stepMs?: number;
}) {
const [ms, setMs] = useState(initialMs);
return (
<>
Expand All @@ -12,9 +22,9 @@ function Harness({ initialMs = 5000 }: { initialMs?: number }) {
label="Timeout"
valueMs={ms}
onValueMsChange={setMs}
minMs={1000}
maxMs={60000}
stepMs={500}
minMs={minMs}
maxMs={maxMs}
stepMs={stepMs}
/>
<output>{ms}</output>
</>
Expand Down Expand Up @@ -48,13 +58,30 @@ describe("DurationSliderField", () => {
const input = screen.getByLabelText("Timeout");

fireEvent.change(input, { target: { value: "90" } });
expect(input).toHaveValue(90);
fireEvent.blur(input);
expect(input).toHaveValue(60);
expect(screen.getByRole("status")).toHaveTextContent("60000");

fireEvent.change(input, { target: { value: "0" } });
expect(input).toHaveValue(0);
fireEvent.blur(input);
expect(input).toHaveValue(1);
expect(screen.getByRole("status")).toHaveTextContent("1000");
});

it("lets the user type a value above a high minimum before blur", () => {
render(<Harness initialMs={300000} minMs={300000} maxMs={86400000} stepMs={1000} />);

const input = screen.getByLabelText("Timeout");
expect(input).toHaveValue(300);

fireEvent.change(input, { target: { value: "6" } });
expect(input).toHaveValue(6);
fireEvent.change(input, { target: { value: "600" } });
expect(input).toHaveValue(600);
fireEvent.blur(input);
expect(input).toHaveValue(600);
expect(screen.getByRole("status")).toHaveTextContent("600000");
});
});
22 changes: 19 additions & 3 deletions frontend/components/app/duration-slider-field.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
Expand All @@ -26,8 +27,11 @@ export function DurationSliderField({
maxMs: number;
stepMs: number;
}) {
const [draft, setDraft] = useState<string | null>(null);

function commitSeconds(raw: string) {
const seconds = parseFloat(raw);
setDraft(null);
if (!Number.isFinite(seconds)) {
onValueMsChange(clampMs(valueMs, minMs, maxMs, stepMs));
return;
Expand All @@ -46,9 +50,20 @@ export function DurationSliderField({
min={minMs / 1000}
max={maxMs / 1000}
step={stepMs / 1000}
className="h-8 w-16 text-right font-mono text-sm"
value={valueMs / 1000}
onChange={(event) => commitSeconds(event.target.value)}
className="h-8 w-20 text-right font-mono text-sm"
value={draft ?? valueMs / 1000}
onChange={(event) => {
const raw = event.target.value;
setDraft(raw);
const seconds = parseFloat(raw);
if (!Number.isFinite(seconds)) {
return;
}
const nextMs = Math.round(seconds * 1000);
if (nextMs >= minMs && nextMs <= maxMs) {
onValueMsChange(clampMs(nextMs, minMs, maxMs, stepMs));
}
}}
onBlur={(event) => commitSeconds(event.target.value)}
/>
<span className="text-sm text-muted-foreground font-mono">s</span>
Expand All @@ -60,6 +75,7 @@ export function DurationSliderField({
step={stepMs}
value={[valueMs]}
onValueChange={(value) => {
setDraft(null);
const next = Array.isArray(value) ? value[0] : value;
onValueMsChange(clampMs(next, minMs, maxMs, stepMs));
}}
Expand Down
Loading