diff --git a/package-lock.json b/package-lock.json index 13d27c7..e171945 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "site-status", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.96.0", "next": "^15.2.0", @@ -54,6 +58,73 @@ "commander": "~13.1.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", diff --git a/package.json b/package.json index ab5ee8c..77b6118 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "test:e2e": "env-cmd -f .env.test playwright test" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.96.0", "next": "^15.2.0", diff --git a/src/__tests__/sites-crud.test.ts b/src/__tests__/sites-crud.test.ts index 6d61d16..d3e7404 100644 --- a/src/__tests__/sites-crud.test.ts +++ b/src/__tests__/sites-crud.test.ts @@ -26,7 +26,7 @@ vi.mock("@/lib/supabase/server", () => ({ }), })) -import { addSite, editSite, deleteSite } from "@/app/sites/actions" +import { addSite, editSite, deleteSite, reorderSites } from "@/app/sites/actions" describe("addSite action", () => { beforeEach(() => { @@ -49,7 +49,17 @@ describe("addSite action", () => { }) const mockInsert = vi.fn().mockReturnValue({ error: null }) - mockFrom.mockReturnValue({ insert: mockInsert }) + const mockSingle = vi.fn().mockResolvedValue({ data: { position: 2 } }) + const mockLimit = vi.fn().mockReturnValue({ single: mockSingle }) + const mockOrder = vi.fn().mockReturnValue({ limit: mockLimit }) + const mockSelect = vi.fn().mockReturnValue({ order: mockOrder }) + mockFrom.mockImplementation((table: string) => { + // First call: select max position; second call: insert + if (mockFrom.mock.calls.length <= 1) { + return { select: mockSelect } + } + return { insert: mockInsert } + }) const formData = new FormData() formData.set("name", "Test Site") @@ -60,6 +70,7 @@ describe("addSite action", () => { expect(mockInsert).toHaveBeenCalledWith({ name: "Test Site", url: "https://example.com", + position: 3, }) }) @@ -154,3 +165,36 @@ describe("deleteSite action", () => { expect(mockEq).toHaveBeenCalledWith("id", "site-1") }) }) + +describe("reorderSites action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test("redirects unauthenticated users to login", async () => { + mockGetUser.mockResolvedValue({ data: { user: null } }) + + await expect(reorderSites(["a", "b"])).rejects.toThrow( + "NEXT_REDIRECT:/login" + ) + }) + + test("updates positions for authenticated users", async () => { + mockGetUser.mockResolvedValue({ + data: { user: { id: "user-1" } }, + }) + + const mockEq = vi.fn().mockReturnValue({ error: null }) + const mockUpdate = vi.fn().mockReturnValue({ eq: mockEq }) + mockFrom.mockReturnValue({ update: mockUpdate }) + + await reorderSites(["site-b", "site-a", "site-c"]) + expect(mockFrom).toHaveBeenCalledWith("sites") + expect(mockUpdate).toHaveBeenCalledWith({ position: 0 }) + expect(mockUpdate).toHaveBeenCalledWith({ position: 1 }) + expect(mockUpdate).toHaveBeenCalledWith({ position: 2 }) + expect(mockEq).toHaveBeenCalledWith("id", "site-b") + expect(mockEq).toHaveBeenCalledWith("id", "site-a") + expect(mockEq).toHaveBeenCalledWith("id", "site-c") + }) +}) diff --git a/src/app/page.tsx b/src/app/page.tsx index 3873630..4077834 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,8 @@ import Link from "next/link" import { createClient } from "@/lib/supabase/server" import type { Site, Check, Incident } from "@/lib/supabase/types" -import { formatTimeAgo, formatDuration } from "@/lib/format" -import AddSiteCard from "@/components/AddSiteCard" -import SiteFormDialog from "@/components/SiteFormDialog" +import { formatDuration } from "@/lib/format" +import SiteList from "@/components/SiteList" import RealtimeStatusPage from "@/components/RealtimeStatusPage" export const revalidate = 0 @@ -17,7 +16,7 @@ async function getStatusData() { const { data: sites } = await supabase .from("sites") .select("*") - .order("name") + .order("position") const { data: openIncidents } = await supabase .from("incidents") @@ -127,103 +126,7 @@ export default async function StatusPage() { Sites - {sites.length === 0 && !isAdmin ? ( -

- No sites are being monitored yet. -

- ) : ( -
- {sites.map((site) => ( -
- -
-
- - - {site.name} - -
- - {site.url} - - {isAdmin && ( -
- - - - - - - } - /> -
- )} - - {site.lastCheck - ? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}` - : "No checks yet"} - -
-
- ))} - {isAdmin && } -
- )} + ) diff --git a/src/app/sites/actions.ts b/src/app/sites/actions.ts index c44080c..8e4bb95 100644 --- a/src/app/sites/actions.ts +++ b/src/app/sites/actions.ts @@ -17,7 +17,16 @@ export async function addSite(formData: FormData) { if (!name || !url) return - await supabase.from("sites").insert({ name, url }) + const { data: maxRow } = await supabase + .from("sites") + .select("position") + .order("position", { ascending: false }) + .limit(1) + .single() + + const position = (maxRow?.position ?? -1) + 1 + + await supabase.from("sites").insert({ name, url, position }) revalidatePath("/") } @@ -50,3 +59,21 @@ export async function deleteSite(siteId: string) { await supabase.from("sites").delete().eq("id", siteId) revalidatePath("/") } + +export async function reorderSites(orderedIds: string[]) { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) redirect("/login") + + for (let i = 0; i < orderedIds.length; i++) { + await supabase + .from("sites") + .update({ position: i }) + .eq("id", orderedIds[i]) + } + + revalidatePath("/") +} diff --git a/src/components/SiteList.tsx b/src/components/SiteList.tsx new file mode 100644 index 0000000..3445906 --- /dev/null +++ b/src/components/SiteList.tsx @@ -0,0 +1,274 @@ +"use client" + +import { useRef, useState } from "react" +import Link from "next/link" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core" +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { restrictToVerticalAxis } from "@dnd-kit/modifiers" +import type { Site, Check } from "@/lib/supabase/types" +import { formatTimeAgo } from "@/lib/format" +import { reorderSites } from "@/app/sites/actions" +import SiteFormDialog from "@/components/SiteFormDialog" +import AddSiteCard from "@/components/AddSiteCard" + +type SiteWithLastCheck = Site & { lastCheck: Check | null } + +function SortableSiteCard({ + site, + isAdmin, +}: { + site: SiteWithLastCheck + isAdmin: boolean +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: site.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( +
+ + +
+ ) +} + +function SiteCard({ + site, + isAdmin, + isWrapped, +}: { + site: SiteWithLastCheck + isAdmin: boolean + isWrapped?: boolean +}) { + return ( +
+ +
+
+ + + {site.name} + +
+ + {site.url} + + {isAdmin && ( +
+ + + + + + + } + /> +
+ )} + + {site.lastCheck + ? `Checked ${formatTimeAgo(site.lastCheck.checked_at)}` + : "No checks yet"} + +
+
+ ) +} + +export default function SiteList({ + sites: initialSites, + isAdmin, +}: { + sites: SiteWithLastCheck[] + isAdmin: boolean +}) { + const [sites, setSites] = useState(initialSites) + + // Sync local state when server data changes (after add/edit/delete revalidation) + const serverKey = initialSites.map((s) => `${s.id}:${s.name}:${s.url}`).join("|") + const prevServerKey = useRef(serverKey) + if (serverKey !== prevServerKey.current) { + prevServerKey.current = serverKey + setSites(initialSites) + } + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = sites.findIndex((s) => s.id === active.id) + const newIndex = sites.findIndex((s) => s.id === over.id) + const reordered = arrayMove(sites, oldIndex, newIndex) + + setSites(reordered) + reorderSites(reordered.map((s) => s.id)) + } + + if (sites.length === 0 && !isAdmin) { + return ( +

+ No sites are being monitored yet. +

+ ) + } + + if (!isAdmin) { + return ( +
+ {sites.map((site) => ( + + ))} +
+ ) + } + + return ( + + s.id)} + strategy={verticalListSortingStrategy} + > +
+ {sites.map((site) => ( + + ))} + +
+
+
+ ) +} diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 36625df..8241eb0 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -2,6 +2,7 @@ export type Site = { id: string name: string url: string + position: number created_at: string } diff --git a/supabase/migrations/20250222000001_add_site_position.sql b/supabase/migrations/20250222000001_add_site_position.sql new file mode 100644 index 0000000..41ef7bc --- /dev/null +++ b/supabase/migrations/20250222000001_add_site_position.sql @@ -0,0 +1,10 @@ +-- Add position column for custom ordering +ALTER TABLE sites ADD COLUMN position integer NOT NULL DEFAULT 0; + +-- Backfill existing rows with sequential positions based on alphabetical order +WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER (ORDER BY name) - 1 AS pos + FROM sites +) +UPDATE sites SET position = ranked.pos +FROM ranked WHERE sites.id = ranked.id; diff --git a/supabase/seed.sql b/supabase/seed.sql index 4c4d843..a187c5c 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -62,12 +62,74 @@ INSERT INTO auth.identities ( ); -- ============================================================================ --- Sample sites +-- Sample sites (4 sites to demonstrate each status) -- ============================================================================ -INSERT INTO sites (id, name, url) VALUES - ('11111111-1111-1111-1111-111111111111', 'Example Site', 'https://example.com'), - ('22222222-2222-2222-2222-222222222222', 'Anthropic', 'https://www.anthropic.com'); +INSERT INTO sites (id, name, url, position) VALUES + ('11111111-1111-1111-1111-111111111111', 'Healthy Site', 'https://example.com', 0), + ('22222222-2222-2222-2222-222222222222', 'Flaky Site', 'https://flaky.example.com', 1), + ('33333333-3333-3333-3333-333333333333', 'Broken Site', 'https://broken.example.com', 2), + ('44444444-4444-4444-4444-444444444444', 'Down Site (Incident)', 'https://down.example.com', 3); + +-- ============================================================================ +-- Synthetic checks (last hour) to demonstrate all three site statuses +-- ============================================================================ + +-- Site 1: "Healthy Site" — all passing → status: Up (green) +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0001-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0001-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0001-0000-0000-0000-000000000003', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0001-0000-0000-0000-000000000004', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0001-0000-0000-0000-000000000005', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '30 minutes'), + ('aaaa0001-0000-0000-0000-000000000006', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '25 minutes'), + ('aaaa0001-0000-0000-0000-000000000007', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '20 minutes'), + ('aaaa0001-0000-0000-0000-000000000008', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '15 minutes'), + ('aaaa0001-0000-0000-0000-000000000009', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '10 minutes'), + ('aaaa0001-0000-0000-0000-000000000010', '11111111-1111-1111-1111-111111111111', 'success', 200, NULL, now() - interval '5 minutes'); + +-- Site 2: "Flaky Site" — mostly passing with one 503 → status: Transient Failures (amber) +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0002-0000-0000-0000-000000000001', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0002-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0002-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0002-0000-0000-0000-000000000004', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0002-0000-0000-0000-000000000005', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '30 minutes'), + ('aaaa0002-0000-0000-0000-000000000006', '22222222-2222-2222-2222-222222222222', 'failure', 503, 'HTTP 503', now() - interval '25 minutes'), + ('aaaa0002-0000-0000-0000-000000000007', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '20 minutes'), + ('aaaa0002-0000-0000-0000-000000000008', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '15 minutes'), + ('aaaa0002-0000-0000-0000-000000000009', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '10 minutes'), + ('aaaa0002-0000-0000-0000-000000000010', '22222222-2222-2222-2222-222222222222', 'success', 200, NULL, now() - interval '5 minutes'); + +-- Site 3: "Broken Site" — has a hard failure (HTTP 500) → status: Failures (red) +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0003-0000-0000-0000-000000000001', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0003-0000-0000-0000-000000000002', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0003-0000-0000-0000-000000000003', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0003-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0003-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '30 minutes'), + ('aaaa0003-0000-0000-0000-000000000006', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '25 minutes'), + ('aaaa0003-0000-0000-0000-000000000007', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '20 minutes'), + ('aaaa0003-0000-0000-0000-000000000008', '33333333-3333-3333-3333-333333333333', 'failure', 500, 'HTTP 500', now() - interval '15 minutes'), + ('aaaa0003-0000-0000-0000-000000000009', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '10 minutes'), + ('aaaa0003-0000-0000-0000-000000000010', '33333333-3333-3333-3333-333333333333', 'success', 200, NULL, now() - interval '5 minutes'); + +-- Site 4: "Down Site (Incident)" — 3 soft failures triggered an incident, still failing +INSERT INTO checks (id, site_id, status, status_code, error, checked_at) VALUES + ('aaaa0004-0000-0000-0000-000000000001', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '50 minutes'), + ('aaaa0004-0000-0000-0000-000000000002', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '45 minutes'), + ('aaaa0004-0000-0000-0000-000000000003', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '40 minutes'), + ('aaaa0004-0000-0000-0000-000000000004', '44444444-4444-4444-4444-444444444444', 'success', 200, NULL, now() - interval '35 minutes'), + ('aaaa0004-0000-0000-0000-000000000005', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '30 minutes'), + ('aaaa0004-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', 'failure', 502, 'HTTP 502', now() - interval '25 minutes'), + ('aaaa0004-0000-0000-0000-000000000007', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '20 minutes'), + ('aaaa0004-0000-0000-0000-000000000008', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '15 minutes'), + ('aaaa0004-0000-0000-0000-000000000009', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '10 minutes'), + ('aaaa0004-0000-0000-0000-000000000010', '44444444-4444-4444-4444-444444444444', 'failure', 503, 'HTTP 503', now() - interval '5 minutes'); + +-- Open incident for Site 4 (triggered by the 3rd soft failure) +INSERT INTO incidents (id, site_id, check_id, status, opened_at) VALUES + ('bbbb0001-0000-0000-0000-000000000001', '44444444-4444-4444-4444-444444444444', 'aaaa0004-0000-0000-0000-000000000007', 'open', now() - interval '20 minutes'); -- ============================================================================ -- Test contact