From ddb362fcac80c1282b1e6a99f2c0e44b606673a6 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:31:58 +0200 Subject: [PATCH] feat(dashboard): empty states for data tables and charts (#2973) * feat(dashboard): empty states for data tables and charts Every list, chart slot and section now shares one empty-state kit: icon, what belongs here, why it is empty, one action, one escape hatch. Tables render it in the body under the real header with ghost rows; charts fill their slot at the chart's height so a resolving Tinybird fetch never shifts the page; a filter that matches nothing gets its own "No matches" row with a clear action. Metrics show an em dash instead of 100% / 0 ms / N/A when a window has no checks, a paused monitor keeps its history, and status report and maintenance sheets accept a controlled open so a page mounts one sheet for both its header and empty-state buttons. * fix(dashboard): address empty-state review findings - fix free-plan region empty state, controlled sheets, uptime cards on failed fetch, regions table without configured regions - drop refs written during render in the monitor list; export the status union from search-params - em dash instead of "No data" for missing latency cells; update the metrics test and cover hasMetricsData - audit-log settings page uses the DataTable empty state - share the private-location create sheet (forms/private-location/sheet-create) - tighten empty-state copy on overview, incidents, status reports and the monitor timeline; rewrite the DESIGN.md empty-state paragraph - login: restyle the last-used badge so it reads on both button variants * fix(dashboard): empty-state follow-ups from review - audit-log settings page stays mounted on a failed list and says so - regions table: filter that matches no configured region gets its own state with a show-all action instead of "No regions configured" - overview recent activity: don't claim no activity when ended incidents failed to load * feat(dashboard): remaining tables use the DataTable empty state connected apps, API keys, pending invitations, the monitor timeline and status page history render the table with emptyState instead of swapping it for an EmptyStateContainer * fix(dashboard): monitor overview empty states - drop the widen-time-range action on the uptime chart - regions table uses the ghost-row empty state and hides the axis header without rows - monitor timeline skips the collapse wrapper when empty * feat(dashboard): align data-table ghost rows to the header columns Ghost rows are real table rows now, so the skeleton pills share the header column widths and alignment. The empty state pulls itself up over five rows behind a gradient overlay. Columns without visible header text use header "" or meta.ghost = false to stay blank. * fix(dashboard): empty-state cell wraps, ghost header check fails soft * fix(dashboard): monitor list filters keep every status in the URL Updater functions resolve against a ref so two changes in one tick both apply, and the status param is an array. Empty-state copy tweaks: MCP docs action on connected apps, globe icon for private locations, opaque media tints over the ghost rows. * fix(dashboard): monitor status filter keeps every selected status --- DESIGN.md | 15 +- .../src/app/(dashboard)/incidents/client.tsx | 40 +++-- .../src/app/(dashboard)/invite/client.tsx | 2 +- .../(dashboard)/monitors/(list)/client.tsx | 100 ++++++++--- .../monitors/(list)/search-params.ts | 11 +- .../monitors/[id]/incidents/page.tsx | 36 ++-- .../monitors/[id]/overview/client.tsx | 157 +++++++++++++++--- .../app/(dashboard)/notifications/client.tsx | 55 ++++-- .../src/app/(dashboard)/overview/page.tsx | 74 +++++---- .../settings/audit-logs/client.tsx | 50 ++++-- .../integrations/connected-apps-card.tsx | 39 ++++- .../integrations/slack/link/client.tsx | 2 +- .../settings/private-locations/client.tsx | 41 ++++- .../private-locations/nav-actions.tsx | 30 +--- .../status-pages/(list)/client.tsx | 33 +++- .../status-pages/[id]/history/client.tsx | 35 ++-- .../[id]/maintenances/(list)/page.tsx | 73 ++++++-- .../[id]/status-reports/(list)/page.tsx | 110 ++++++++---- .../status-pages/[id]/subscribers/page.tsx | 48 ++++-- .../src/app/login/_components/email-form.tsx | 2 +- .../app/login/_components/login-button.tsx | 9 +- .../src/components/chart/chart-empty.tsx | 19 +++ .../src/components/content/empty-state.tsx | 83 ++++++++- .../audit-logs-workspace/columns.tsx | 4 +- .../data-table/audit-logs/columns.tsx | 2 +- .../data-table/audit-logs/wrapper.tsx | 44 ++--- .../data-table/incidents/columns.tsx | 2 +- .../data-table/monitors/columns.tsx | 24 +-- .../data-table/overview-events/columns.tsx | 10 +- .../data-table/response-logs/columns.tsx | 2 +- .../response-logs/regions/columns.tsx | 6 +- .../response-logs/regions/data-table.tsx | 74 ++++++++- .../data-table/settings/api-key/columns.tsx | 1 + .../settings/api-key/data-table.tsx | 19 +++ .../settings/connected-apps/columns.tsx | 1 + .../settings/invitations/columns.tsx | 1 + .../settings/invitations/data-table.tsx | 27 +-- .../data-table/settings/members/columns.tsx | 1 + .../data-table/table-cell-number.tsx | 2 +- .../forms/maintenance/sheet-create.tsx | 5 +- .../components/forms/maintenance/sheet.tsx | 19 ++- .../forms/private-location/sheet-create.tsx | 45 +++++ .../forms/settings/form-api-key.tsx | 16 +- .../forms/status-report/sheet-create.tsx | 5 +- .../components/forms/status-report/sheet.tsx | 15 +- .../metric/global-uptime/section.tsx | 49 +++--- .../ui/data-table/data-table-empty.tsx | 111 +++++++++++++ .../components/ui/data-table/data-table.tsx | 55 +++++- .../dashboard/src/data/metrics.client.test.ts | 36 +++- apps/dashboard/src/data/metrics.client.ts | 13 +- apps/dashboard/src/lib/react-table.d.ts | 2 + packages/icons/src/lucide/index.ts | 1 + packages/icons/src/nucleo/index.ts | 1 + packages/icons/src/nucleo/pause.tsx | 46 +++++ 54 files changed, 1324 insertions(+), 379 deletions(-) create mode 100644 apps/dashboard/src/components/chart/chart-empty.tsx create mode 100644 apps/dashboard/src/components/forms/private-location/sheet-create.tsx create mode 100644 apps/dashboard/src/components/ui/data-table/data-table-empty.tsx create mode 100644 packages/icons/src/nucleo/pause.tsx diff --git a/DESIGN.md b/DESIGN.md index 1a4da6e52..9dc99f6d8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -108,8 +108,19 @@ primitive (`docs/adr/0003`). - **Spacing.** Lists `gap-4`, cards `px-4`, inside a row `gap-2.5`, stacked compact rows pull up with a short rail. Pages cap at `max-w-6xl`. Padding is symmetric unless the asymmetry is explained in a comment. -- **Empty states** use the dashed `EmptyStateContainer` with a title and one - sentence that says what to do next. Never a bare "No data". +- **Empty states** share one anatomy from the `EmptyState*` parts: icon + (`EmptyStateMedia`), what belongs here (title), why it is empty (one + sentence), then optionally one primary action and one escape hatch + (`EmptyStateActions`). The wrapper picks the slot: `EmptyStateContainer` + for a section, its `inline` prop for a single row inside a table or card, + `DataTableEmpty` ghost rows for a table body on first run (via `DataTable + emptyState`), `ChartEmpty` at the chart's own height so a resolving fetch + never shifts the page. A filter that matches nothing is the table's own + "No matches" row with a clear action. A short slot drops the sentence, + never the icon. A table without its own `emptyState` falls back to a + generic icon and sentence; a primary list always passes its own. Never a + bare "No data", and never `100%`, `0 ms` or `N/A` for data that does not + exist: use an em dash. - **Inline editing.** Title and summary are borderless inputs that show their edge on hover and focus. Save on blur or explicit action; never on every keystroke. diff --git a/apps/dashboard/src/app/(dashboard)/incidents/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/client.tsx index b0bf111b2..d83aab386 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/client.tsx @@ -1,10 +1,14 @@ "use client"; +import { Incident } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; import { - EmptyStateContainer, + EmptyStateActions, EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { @@ -15,7 +19,9 @@ import { SectionTitle, } from "@/components/content/section"; import { columns } from "@/components/data-table/managed-incidents/columns"; +import { DeclareIncidentButton } from "@/components/incidents/declare-incident-button"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; @@ -32,20 +38,32 @@ export function Client() { Declare and track the incidents your team is responding to. - {!incidents ? null : incidents.length === 0 ? ( - - No incidents declared - - Declared incidents keep the timeline your team builds while - responding. Nothing here is public: use a status report to tell - your users. - - - ) : ( + {!incidents ? null : ( + + + + No incidents declared + + A declared incident keeps the timeline your team builds while + responding. Nothing here is public: a status report is what + tells your users. + + + + + + + + + } /> )} diff --git a/apps/dashboard/src/app/(dashboard)/invite/client.tsx b/apps/dashboard/src/app/(dashboard)/invite/client.tsx index afe6711ad..6cf92a14a 100644 --- a/apps/dashboard/src/app/(dashboard)/invite/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/invite/client.tsx @@ -63,7 +63,7 @@ export function Client() { This invitation can't be opened. - + Invitation unavailable {error.message} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx index 969521ed9..c486a0ad6 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx @@ -1,12 +1,20 @@ "use client"; import type { RouterInputs } from "@openstatus/api"; -import { ArrowDown, Success, ListFilter } from "@openstatus/icons"; +import { ArrowDown, Success, ListFilter, Monitor } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; import { useQueries, useQuery } from "@tanstack/react-query"; import type { ColumnFiltersState, SortingState } from "@tanstack/react-table"; +import NextLink from "next/link"; import { useQueryStates } from "nuqs"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { + EmptyStateActions, + EmptyStateDescription, + EmptyStateMedia, + EmptyStateTitle, +} from "@/components/content/empty-state"; import { Section, SectionDescription, @@ -26,11 +34,12 @@ import { MetricCardValue, } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { getMonitorListMetrics } from "@/data/metrics.client"; import { useTRPC } from "@/lib/trpc/client"; -import { searchParamsParsers } from "./search-params"; +import { type MonitorStatus, searchParamsParsers } from "./search-params"; type MetricType = NonNullable< RouterInputs["tinybird"]["globalMetrics"]["type"] @@ -64,6 +73,9 @@ export function Client() { const [searchParams, setSearchParams] = useQueryStates(searchParamsParsers); const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); + // latest values for updater chains within one tick (the render closure lags) + const sortingRef = useRef(sorting); + const columnFiltersRef = useRef(columnFilters); const metricQueries = useQueries({ queries: METRIC_TYPES.map((type) => { @@ -82,11 +94,13 @@ export function Client() { // TODO: ideally we read from the searchParamsCache and there is no layout shift useEffect(() => { - if (searchParams.status) { - setColumnFilters([{ id: "status", value: [searchParams.status] }]); + if (searchParams.status?.length) { + columnFiltersRef.current = [{ id: "status", value: searchParams.status }]; + setColumnFilters(columnFiltersRef.current); } if (searchParams.sort) { - setSorting([searchParams.sort]); + sortingRef.current = [searchParams.sort]; + setSorting(sortingRef.current); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -95,6 +109,32 @@ export function Client() { const metrics = getMonitorListMetrics(monitors, globalMetrics); + // every filter or sort change goes through here, from the table or the cards, + // so the URL mirrors the state + const onColumnFiltersChange: React.Dispatch< + React.SetStateAction + > = (updater) => { + const next = + typeof updater === "function" + ? updater(columnFiltersRef.current) + : updater; + columnFiltersRef.current = next; + setColumnFilters(next); + const status = next.find((f) => f.id === "status")?.value as + | MonitorStatus[] + | undefined; + setSearchParams({ status: status?.length ? status : null }); + }; + const onSortingChange: React.Dispatch> = ( + updater, + ) => { + const next = + typeof updater === "function" ? updater(sortingRef.current) : updater; + sortingRef.current = next; + setSorting(next); + setSearchParams({ sort: next[0] ?? null }); + }; + return (
@@ -126,21 +166,17 @@ export function Client() { variant={metric.variant} onClick={() => { if (metric.key === "p95") { - if (sorting.length === 0 || !isActive) { - setSearchParams({ sort: { id: "p95", desc: true } }); - setSorting([{ id: "p95", desc: true }]); - } else { - setSearchParams({ sort: null }); - setSorting([]); - } + onSortingChange( + sorting.length === 0 || !isActive + ? [{ id: "p95", desc: true }] + : [], + ); } else { - if (columnFilters.length === 0 || !isActive) { - setSearchParams({ status: metric.key }); - setColumnFilters([{ id: "status", value: [metric.key] }]); - } else { - setSearchParams({ status: null }); - setColumnFilters([]); - } + onColumnFiltersChange( + columnFilters.length === 0 || !isActive + ? [{ id: "status", value: [metric.key] }] + : [], + ); } }} > @@ -176,10 +212,30 @@ export function Client() { )} paginationComponent={DataTablePaginationSimple} + emptyState={ + + + + + No monitors yet + + A monitor checks an endpoint on a schedule from the regions you + pick and alerts you when it degrades or fails. + + + + + + + } columnFilters={columnFilters} - setColumnFilters={setColumnFilters} + setColumnFilters={onColumnFiltersChange} sorting={sorting} - setSorting={setSorting} + setSorting={onSortingChange} defaultColumnVisibility={{ active: false, url: false, diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/search-params.ts b/apps/dashboard/src/app/(dashboard)/monitors/(list)/search-params.ts index 4843b0ddf..9814a9f80 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/(list)/search-params.ts +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/search-params.ts @@ -1,6 +1,7 @@ import { createParser, createSearchParamsCache, + parseAsArrayOf, parseAsStringEnum, } from "nuqs/server"; @@ -15,8 +16,16 @@ export const parseAsSort = createParser({ }, }); +export const MONITOR_STATUSES = [ + "active", + "degraded", + "error", + "inactive", +] as const; +export type MonitorStatus = (typeof MONITOR_STATUSES)[number]; + export const searchParamsParsers = { - status: parseAsStringEnum(["active", "degraded", "error", "inactive"]), + status: parseAsArrayOf(parseAsStringEnum([...MONITOR_STATUSES])), sort: parseAsSort, }; diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx index 86020fb57..d6e57751f 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx @@ -1,14 +1,14 @@ "use client"; -import { Info } from "@openstatus/icons"; +import { Info, Success } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { Link } from "@/components/common/link"; import { Note } from "@/components/common/note"; import { - EmptyStateContainer, EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { @@ -20,6 +20,7 @@ import { } from "@/components/content/section"; import { columns } from "@/components/data-table/incidents/columns"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; @@ -60,20 +61,23 @@ export default function Page() { )} - {monitorIncidents.length === 0 ? ( - - No downtime - - No downtime recorded for this monitor. - - - ) : ( - - )} + + + + + No downtime recorded + + A failed check opens a downtime entry here and alerts the + channels linked to this monitor. Nothing has failed so far. + + + } + />
); diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx index 4fe52c33a..e058cf554 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx @@ -1,7 +1,10 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { Pause, Play, Trend } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { endOfDay } from "date-fns"; +import NextLink from "next/link"; import { useParams } from "next/navigation"; import { useQueryStates } from "nuqs"; import React, { useEffect } from "react"; @@ -10,9 +13,12 @@ import { toast } from "sonner"; import { ChartAreaLatency } from "@/components/chart/chart-area-latency"; import { ChartAreaTimingPhases } from "@/components/chart/chart-area-timing-phases"; import { ChartBarUptime } from "@/components/chart/chart-bar-uptime"; +import { ChartEmpty } from "@/components/chart/chart-empty"; import { + EmptyStateActions, EmptyStateContainer, EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { @@ -38,6 +44,7 @@ import { PopoverQuantile } from "@/components/popovers/popover-quantile"; import { PopoverResolution } from "@/components/popovers/popover-resolution"; import { FREE_MAX_PERIOD, + hasMetricsData, isPaidPeriod, mapRegionMetrics, } from "@/data/metrics.client"; @@ -58,6 +65,15 @@ export function Client() { trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const queryClient = useQueryClient(); + const resumeMonitor = useMutation( + trpc.monitor.updateMonitors.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.monitor.pathKey() }); + }, + onError: () => toast.error("Failed to resume the monitor"), + }), + ); const isBlockedPeriod = workspace?.plan === "free" && isPaidPeriod(period); // snap free workspaces back synchronously so charts never fire (and the server @@ -101,24 +117,55 @@ export function Client() { const { data: regionTimeline, isError: isRegionTimelineError } = useQuery(regionTimelineQuery); + const { data: metrics, isLoading: isMetricsLoading } = useQuery({ + ...trpc.tinybird.metrics.queryOptions({ + monitorId: id, + period: effectivePeriod, + type: (monitor?.jobType ?? "http") as + | "http" + | "tcp" + | "dns" + | "icmp" + | "grpc", + regions: selectedRegions, + }), + enabled: !!monitor, + }); + const noData = !!metrics && !hasMetricsData(metrics); + // a paused monitor keeps its history; the paused card only fills an empty slot + const paused = monitor?.active === false && noData; // free plans ignore the region filter server-side, so they keep every row const filtersRegions = workspace?.plan !== "free" && !!selectedRegions?.length; + const filteredOut = noData && filtersRegions; + const maxPeriod = workspace?.plan === "free" ? FREE_MAX_PERIOD : "90d"; - const regionMetrics: RegionMetric[] = React.useMemo(() => { - const configured = [ + const configuredRegions = React.useMemo( + () => [ ...(monitor?.regions ?? []), ...(monitor?.privateLocations?.map((location) => location.id.toString(), ) ?? []), - ]; - return mapRegionMetrics( - regionTimeline, - filtersRegions - ? configured.filter((region) => selectedRegions?.includes(region)) - : configured, - ); - }, [regionTimeline, monitor, filtersRegions, selectedRegions]); + ], + [monitor], + ); + const regionMetrics: RegionMetric[] = React.useMemo( + () => + mapRegionMetrics( + regionTimeline, + filtersRegions + ? configuredRegions.filter((region) => + selectedRegions?.includes(region), + ) + : configuredRegions, + ), + [regionTimeline, configuredRegions, filtersRegions, selectedRegions], + ); + // e.g. a stale region in the URL after the monitor's regions were edited + const regionsFilteredOut = + filtersRegions && + configuredRegions.length > 0 && + regionMetrics.length === 0; if (!monitor) return null; @@ -151,12 +198,7 @@ export function Client() { privateLocations={monitor.privateLocations} /> - +
@@ -165,12 +207,59 @@ export function Client() { Uptime across all the selected regions - + {/* the slot keeps the chart's height so the tinybird fetch never shifts the page */} + {paused ? ( + + + + + This monitor is paused + + + + + + ) : noData ? ( + + + + + + {filteredOut + ? "No checks from the selected regions" + : "No checks in this period"} + + {filteredOut ? ( + + + + ) : null} + + ) : ( + + )}
{/* TODO: based on http, we have Timing Phases instead of Latency */} @@ -185,7 +274,25 @@ export function Client() { within a {" "} resolution - {monitor.jobType === "http" ? ( + {noData ? ( + + {paused ? : } + + {paused + ? "No latency while paused" + : filteredOut + ? "No latency from the selected regions" + : "No latency in this period"} + + + {paused + ? "Resume the monitor and response times fill in from the first check." + : filteredOut + ? "Pick a region that has reported in this period." + : "The first check lands within a minute of activation."} + + + ) : monitor.jobType === "http" ? ( setSearchParams({ regions: null })} /> )}
diff --git a/apps/dashboard/src/app/(dashboard)/notifications/client.tsx b/apps/dashboard/src/app/(dashboard)/notifications/client.tsx index e1a7e112a..0a6f94b92 100644 --- a/apps/dashboard/src/app/(dashboard)/notifications/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/notifications/client.tsx @@ -1,6 +1,9 @@ "use client"; +import { ExternalLink, Notification } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; +import NextLink from "next/link"; import { useQueryStates } from "nuqs"; import { Link } from "@/components/common/link"; @@ -12,7 +15,9 @@ import { } from "@/components/content/action-card"; import { ActionCardGroup } from "@/components/content/action-card"; import { - EmptyStateContainer, + EmptyStateActions, + EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { @@ -25,6 +30,7 @@ import { import { columns } from "@/components/data-table/notifications/columns"; import { FormSheetNotifier } from "@/components/forms/notifications/sheet"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { config, hiddenProviders } from "@/data/notifications.client"; import { useTRPC } from "@/lib/trpc/client"; @@ -65,25 +71,46 @@ export function Client() {
- {notifications.length === 0 ? ( - - No notifier found - - ) : ( - - )} + + + + + No notification channels yet + + A channel receives an alert when a monitor fails or recovers. + Connect one below and attach it to your monitors. + + + + + + + } + />
-
+
Create a new notifier Define your notifications to receive alerts when downtime occurs.{" "} diff --git a/apps/dashboard/src/app/(dashboard)/overview/page.tsx b/apps/dashboard/src/app/(dashboard)/overview/page.tsx index 8ccf285c4..1866f102b 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/page.tsx @@ -1,13 +1,14 @@ "use client"; -import { Agent } from "@openstatus/icons"; +import { Agent, Clock, Success } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { NoteButton } from "@/components/common/note"; import { NoteDismissible } from "@/components/common/note-dismissible"; import { - EmptyStateContainer, + EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { @@ -28,6 +29,7 @@ import { MetricCardValue, } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { buildOverviewData } from "@/data/overview-events.client"; import { useTRPC } from "@/lib/trpc/client"; @@ -133,21 +135,26 @@ export default function Page() { Everything currently unresolved, newest first. - {needsAttention.length > 0 ? ( - - ) : ( - - - All clear - nothing needs your attention - - - )} + + + + + All clear + + {openIncidents + ? "Nothing is unresolved. Failing monitors, open incidents and status reports show up here." + : "Incidents could not be loaded. Failing monitors and open status reports still show up here."} + + + } + />
{upcomingMaintenances.length > 0 ? (
@@ -174,19 +181,26 @@ export default function Page() { last 7 days. - {recentlyResolved.length > 0 ? ( - - ) : ( - - No recent activity - - )} + + + + + No recent activity + + {endedIncidents + ? "Resolved incidents and reports and completed maintenances from the last 7 days land here." + : "Incidents could not be loaded. Resolved reports and completed maintenances from the last 7 days still show up here."} + + + } + />
); diff --git a/apps/dashboard/src/app/(dashboard)/settings/audit-logs/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/audit-logs/client.tsx index cdb5ab7c3..958c18b5a 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/audit-logs/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/audit-logs/client.tsx @@ -1,7 +1,7 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; -import { Lock } from "@openstatus/icons"; +import { AuditLog, Lock } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; @@ -13,8 +13,8 @@ import { BillingOverlayDescription, } from "@/components/content/billing-overlay"; import { - EmptyStateContainer, EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { @@ -29,6 +29,7 @@ import { DataTableRowDetails } from "@/components/data-table/audit-logs-workspac import { AuditLogsDataTableToolbar } from "@/components/data-table/audit-logs-workspace/data-table-toolbar"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; @@ -99,12 +100,19 @@ export function Client() { const trpc = useTRPC(); const [openDialog, setOpenDialog] = useState(false); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); - const { data: auditLogs } = useQuery(trpc.auditLog.list.queryOptions({})); + const { + data: auditLogs, + isPending: auditLogsPending, + isError: auditLogsError, + } = useQuery(trpc.auditLog.list.queryOptions({})); if (!workspace) return null; const isLimited = workspace.limits["audit-log"] === false; + // a failed list still renders the page with an empty table (see below) + if (!isLimited && auditLogsPending) return null; + return ( @@ -157,10 +165,10 @@ export function Client() { limit="audit-log" /> - ) : auditLogs?.items.length ? ( + ) : ( )} paginationComponent={DataTablePaginationSimple} + emptyState={ + + + + + {auditLogsError ? ( + <> + + Audit logs could not be loaded + + + Try again in a moment. + + + ) : ( + <> + No audit logs yet + + Every create, update and delete in this workspace is + recorded here with who did it and when. + + + )} + + } /> - ) : ( - - No audit logs - - Actions taken in this workspace will appear here. - - )}
diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/connected-apps-card.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/connected-apps-card.tsx index fa8df6777..6a35264cf 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/connected-apps-card.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/connected-apps-card.tsx @@ -1,11 +1,15 @@ "use client"; +import { ExternalLink, Plug } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; +import NextLink from "next/link"; import { Link } from "@/components/common/link"; import { - EmptyStateContainer, + EmptyStateActions, EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { columns } from "@/components/data-table/settings/connected-apps/columns"; @@ -19,6 +23,7 @@ import { FormCardTitle, } from "@/components/forms/form-card"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; @@ -46,19 +51,35 @@ export function ConnectedAppsCard() {

Could not load connected apps. Reload the page to try again.

- ) : grants.length === 0 ? ( - - No connected apps - - Add the openstatus MCP server to Claude, Cursor or ChatGPT and - approve the connection here. - - ) : ( + + + + No connected apps yet + + Add the openstatus MCP server to Claude, Cursor or ChatGPT and + approve the connection here. + + + + + + } /> )} diff --git a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack/link/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack/link/client.tsx index 7cbcaf5ca..5982accdd 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/integrations/slack/link/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/integrations/slack/link/client.tsx @@ -32,7 +32,7 @@ function Message({ children?: React.ReactNode; }) { return ( - + {title} {description}
diff --git a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx index 3e7c26319..a45d52bd1 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/private-locations/client.tsx @@ -1,7 +1,9 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; -import { Lock } from "@openstatus/icons"; +import { ExternalLink, Globe, Lock } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; +import NextLink from "next/link"; import { useState } from "react"; import { Link } from "@/components/common/link"; @@ -11,6 +13,12 @@ import { BillingOverlayContainer, BillingOverlayDescription, } from "@/components/content/billing-overlay"; +import { + EmptyStateActions, + EmptyStateDescription, + EmptyStateMedia, + EmptyStateTitle, +} from "@/components/content/empty-state"; import { Section, SectionDescription, @@ -20,7 +28,9 @@ import { } from "@/components/content/section"; import { columns } from "@/components/data-table/private-locations/columns"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; +import { FormSheetPrivateLocationCreate } from "@/components/forms/private-location/sheet-create"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; @@ -60,6 +70,7 @@ export function Client() { const { data: privateLocations } = useQuery( trpc.privateLocation.list.queryOptions(), ); + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); if (!privateLocations || !workspace) return null; @@ -111,6 +122,34 @@ export function Client() { paginationComponent={DataTablePaginationSimple} columns={columns} data={privateLocations} + emptyState={ + + + + + No private locations + + Run the checker inside your own network to monitor services + the public internet cannot reach. Create one, then start the + checker with its token. + + + + + + + + + } /> )} diff --git a/apps/dashboard/src/app/(dashboard)/settings/private-locations/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/settings/private-locations/nav-actions.tsx index 015cec18a..07ba80c1d 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/private-locations/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/private-locations/nav-actions.tsx @@ -1,29 +1,19 @@ "use client"; import { Button } from "@openstatus/ui/components/ui/button"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { UpgradeDialog } from "@/components/dialogs/upgrade"; -import { FormSheetPrivateLocation } from "@/components/forms/private-location/sheet"; +import { FormSheetPrivateLocationCreate } from "@/components/forms/private-location/sheet-create"; import { NavFeedback } from "@/components/nav/nav-feedback"; import { useTRPC } from "@/lib/trpc/client"; export function NavActions() { const trpc = useTRPC(); - const queryClient = useQueryClient(); const [openDialog, setOpenDialog] = useState(false); const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); - const createPrivateLocationMutation = useMutation( - trpc.privateLocation.new.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.privateLocation.list.queryKey(), - }); - }, - }), - ); if (!workspace || !monitors) return null; @@ -44,21 +34,9 @@ export function NavActions() { Create Private Location ) : ( - { - await createPrivateLocationMutation.mutateAsync({ - name: values.name, - monitors: values.monitors, - token: values.token, - metadata: Object.fromEntries( - values.metadata.map((m) => [m.key, m.value]), - ), - }); - }} - > + - + )} + + + + No status pages yet + + A status page shows the health of your components to your users, + with every report and maintenance posted on it. + + + + + + + } /> diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx index 45eed1e1a..2f29d33ba 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/history/client.tsx @@ -1,6 +1,6 @@ "use client"; -import { Info, Lock } from "@openstatus/icons"; +import { Calendar, Info, Lock } from "@openstatus/icons"; import { Tabs, TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; @@ -17,8 +17,8 @@ import { BillingOverlayDescription, } from "@/components/content/billing-overlay"; import { - EmptyStateContainer, EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { @@ -47,6 +47,7 @@ import { MetricCardValue, } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { HISTORY_WINDOWS, @@ -188,28 +189,28 @@ export function Client() { limit="uptime-history" /> - ) : hasAnyData ? ( + ) : (
+ + + + No history yet + + The first snapshot freezes on the 10th of next month. Until + then, the current month is served live. + + + } />
- ) : ( - - No history yet - - The first{" "} - snapshot{" "} - freezes on the{" "} - - 10th of next month - {" "} - — until then, the current month is served live. - - )} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx index 5f633c506..f6ffa60b7 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx @@ -1,11 +1,20 @@ "use client"; +import { Maintenance } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import NextLink from "next/link"; import { useParams } from "next/navigation"; +import { useState } from "react"; import { toast } from "sonner"; import { Link } from "@/components/common/link"; +import { + EmptyStateActions, + EmptyStateDescription, + EmptyStateMedia, + EmptyStateTitle, +} from "@/components/content/empty-state"; import { Section, SectionDescription, @@ -18,6 +27,7 @@ import { columns } from "@/components/data-table/maintenances/columns"; import { FormSheetMaintenance } from "@/components/forms/maintenance/sheet"; import { toCheckboxTreeItems } from "@/components/ui/checkbox-tree"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; import { errorMessage } from "@/lib/trpc/error"; @@ -26,6 +36,7 @@ export default function Page() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const queryClient = useQueryClient(); + const [openCreate, setOpenCreate] = useState(false); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); @@ -59,6 +70,26 @@ export default function Page() { if (!statusPage || !maintenances) return null; + const items = toCheckboxTreeItems( + statusPage.pageComponents, + statusPage.pageComponentGroups, + ); + const createMaintenance = async ( + values: Parameters< + React.ComponentProps["onSubmit"] + >[0], + ) => { + await createMaintenanceMutation.mutateAsync({ + pageId: Number.parseInt(id), + title: values.title, + message: values.message ?? "", + startDate: values.startDate, + endDate: values.endDate, + pageComponents: values.pageComponents, + notifySubscribers: values.notifySubscribers, + }); + }; + return (
@@ -75,21 +106,10 @@ export default function Page() {
{ - await createMaintenanceMutation.mutateAsync({ - pageId: Number.parseInt(id), - title: values.title, - message: values.message ?? "", - startDate: values.startDate, - endDate: values.endDate, - pageComponents: values.pageComponents, - notifySubscribers: values.notifySubscribers, - }); - }} + open={openCreate} + onOpenChange={setOpenCreate} + items={items} + onSubmit={createMaintenance} > + + + + } />
diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx index 113f2f073..4d9061e23 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx @@ -1,14 +1,21 @@ "use client"; -import { Impact } from "@openstatus/icons"; +import { ExternalLink, Impact, Success } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import NextLink from "next/link"; import { useParams } from "next/navigation"; +import { useState } from "react"; import { Link } from "@/components/common/link"; import { NoteButton } from "@/components/common/note"; import { NoteDismissible } from "@/components/common/note-dismissible"; +import { + EmptyStateActions, + EmptyStateDescription, + EmptyStateMedia, + EmptyStateTitle, +} from "@/components/content/empty-state"; import { Section, SectionDescription, @@ -21,13 +28,16 @@ import { columns } from "@/components/data-table/status-reports/columns"; import { FormSheetStatusReport } from "@/components/forms/status-report/sheet"; import { toCheckboxTreeItems } from "@/components/ui/checkbox-tree"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; +import { getPageUrl } from "@/data/status-pages.client"; import { useTRPC } from "@/lib/trpc/client"; export default function Page() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); const queryClient = useQueryClient(); + const [openCreate, setOpenCreate] = useState(false); const { data: page } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); @@ -58,6 +68,40 @@ export default function Page() { const hasUnresolvedIssue = statusReports.some( (report) => report.status !== "resolved", ); + const items = toCheckboxTreeItems( + page.pageComponents, + page.pageComponentGroups, + ); + const createStatusReport = async ( + values: Parameters< + React.ComponentProps["onSubmit"] + >[0], + ) => { + // NOTE: for type safety, we need to check if the values have a date property + // because of the union type + if ("date" in values) { + // every selected component gets an impact row — fresh + // reports are never legacy; fallback must match the + // picker's defaultImpact + const componentImpacts = values.pageComponents.map((pageComponentId) => ({ + pageComponentId, + impact: + values.componentImpacts?.find( + (ci) => ci.pageComponentId === pageComponentId, + )?.impact ?? ("degraded_performance" as const), + })); + await createStatusReportMutation.mutateAsync({ + title: values.title, + status: values.status, + pageId: Number.parseInt(id), + pageComponents: values.pageComponents, + componentImpacts, + date: values.date, + message: values.message, + notifySubscribers: values.notifySubscribers, + }); + } + }; return ( @@ -87,6 +131,8 @@ export default function Page() {
@@ -96,38 +142,8 @@ export default function Page() { ) : undefined } - items={toCheckboxTreeItems( - page.pageComponents, - page.pageComponentGroups, - )} - onSubmit={async (values) => { - // NOTE: for type safety, we need to check if the values have a date property - // because of the union type - if ("date" in values) { - // every selected component gets an impact row — fresh - // reports are never legacy; fallback must match the - // picker's defaultImpact - const componentImpacts = values.pageComponents.map( - (pageComponentId) => ({ - pageComponentId, - impact: - values.componentImpacts?.find( - (ci) => ci.pageComponentId === pageComponentId, - )?.impact ?? ("degraded_performance" as const), - }), - ); - await createStatusReportMutation.mutateAsync({ - title: values.title, - status: values.status, - pageId: Number.parseInt(id), - pageComponents: values.pageComponents, - componentImpacts, - date: values.date, - message: values.message, - notifySubscribers: values.notifySubscribers, - }); - } - }} + items={items} + onSubmit={createStatusReport} > + + + + } /> diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx index d6a687885..02cb7baf0 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx @@ -1,9 +1,10 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; -import { Lock, Add } from "@openstatus/icons"; +import { Lock, Add, Email, ExternalLink } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery } from "@tanstack/react-query"; +import NextLink from "next/link"; import { notFound, useParams } from "next/navigation"; import { useState } from "react"; @@ -15,8 +16,9 @@ import { BillingOverlayDescription, } from "@/components/content/billing-overlay"; import { - EmptyStateContainer, + EmptyStateActions, EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { @@ -33,7 +35,9 @@ import { UpgradeDialog } from "@/components/dialogs/upgrade"; import { FormSheetSubscriber } from "@/components/forms/subscriber/sheet"; import { toCheckboxTreeItems } from "@/components/ui/checkbox-tree"; import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; +import { getPageUrl } from "@/data/status-pages.client"; import { useTRPC } from "@/lib/trpc/client"; type Subscriber = RouterOutputs["pageSubscriber"]["list"][number]; @@ -190,21 +194,43 @@ export default function Page() { limit="status-subscribers" /> - ) : subscribers?.length ? ( + ) : subscribers ? ( + + + + No subscribers yet + + Visitors subscribe from the public page and get an email on + every update. You can also add people by hand. + + + + {page ? ( + + ) : null} + + + } /> - ) : ( - - No subscribers - - Nobody has been subscribed to this status page. - - - )} + ) : null} ); diff --git a/apps/dashboard/src/app/login/_components/email-form.tsx b/apps/dashboard/src/app/login/_components/email-form.tsx index 8a00211a7..75eac9c50 100644 --- a/apps/dashboard/src/app/login/_components/email-form.tsx +++ b/apps/dashboard/src/app/login/_components/email-form.tsx @@ -57,7 +57,7 @@ export function EmailForm({ if (state.sent) { return ( - + Check your inbox diff --git a/apps/dashboard/src/app/login/_components/login-button.tsx b/apps/dashboard/src/app/login/_components/login-button.tsx index 2225549ea..efb33108c 100644 --- a/apps/dashboard/src/app/login/_components/login-button.tsx +++ b/apps/dashboard/src/app/login/_components/login-button.tsx @@ -29,11 +29,7 @@ export function LoginButton({ return ( + + ) : null} + + ) : data.length === 0 ? ( + <> + No regions configured + + Add a region or private location to the monitor to compare + latency per region. + + + ) : ( + <> + No checks in this period + + None of the {data.length}{" "} + {data.length === 1 ? "region" : "regions"} reported. Pick a + longer period or wait for the next check. + + + )} + + } />
); diff --git a/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx b/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx index 18ff576e4..fb5b0fb38 100644 --- a/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx +++ b/apps/dashboard/src/components/data-table/settings/api-key/columns.tsx @@ -78,6 +78,7 @@ export const columns: ColumnDef[] = [ { id: "actions", header: () => Actions, + meta: { ghost: false }, cell: ({ row }) => , }, ]; diff --git a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx index 568e07f77..07594c19e 100644 --- a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx +++ b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx @@ -1,6 +1,13 @@ import type { RouterOutputs } from "@openstatus/api"; +import { ApiKey as ApiKeyIcon } from "@openstatus/icons"; +import { + EmptyStateDescription, + EmptyStateMedia, + EmptyStateTitle, +} from "@/components/content/empty-state"; import { DataTable as DataTableBase } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { columns } from "./columns"; @@ -13,6 +20,18 @@ export function DataTable({ apiKeys }: { apiKeys: ApiKey[] }) { columns={columns} data={apiKeys} paginationComponent={DataTablePaginationSimple} + emptyState={ + + + + + No API keys yet + + A key lets the CLI, CI/CD and your own scripts read and write + workspace data via the API. + + + } /> ); } diff --git a/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx b/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx index 849232277..4cbea26de 100644 --- a/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx +++ b/apps/dashboard/src/components/data-table/settings/connected-apps/columns.tsx @@ -65,6 +65,7 @@ export const columns: ColumnDef[] = [ { id: "actions", header: () => Actions, + meta: { ghost: false }, cell: ({ row }) => , }, ]; diff --git a/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx b/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx index 793385d93..510976e4a 100644 --- a/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx +++ b/apps/dashboard/src/components/data-table/settings/invitations/columns.tsx @@ -31,6 +31,7 @@ export const columns: ColumnDef[] = [ { id: "actions", header: () => Actions, + meta: { ghost: false }, cell: ({ row }) => , }, ]; diff --git a/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx b/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx index 1d25d173a..41e56114e 100644 --- a/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx +++ b/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx @@ -1,11 +1,13 @@ +import { Invite } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; import { - EmptyStateContainer, EmptyStateDescription, + EmptyStateMedia, EmptyStateTitle, } from "@/components/content/empty-state"; import { DataTable as DataTableBase } from "@/components/ui/data-table/data-table"; +import { DataTableEmpty } from "@/components/ui/data-table/data-table-empty"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; import { useTRPC } from "@/lib/trpc/client"; @@ -17,22 +19,23 @@ export function DataTable() { if (!invitations) return null; - if (invitations.length === 0) { - return ( - - No pending invitations - - Only active invitations are shown here. - - - ); - } - return ( + + + + No pending invitations + + Invite a member below. Accepted and expired invitations drop off + this list. + + + } /> ); } diff --git a/apps/dashboard/src/components/data-table/settings/members/columns.tsx b/apps/dashboard/src/components/data-table/settings/members/columns.tsx index 158628ad4..ec0fb8e11 100644 --- a/apps/dashboard/src/components/data-table/settings/members/columns.tsx +++ b/apps/dashboard/src/components/data-table/settings/members/columns.tsx @@ -45,6 +45,7 @@ export const columns: ColumnDef[] = [ { id: "actions", header: () => Actions, + meta: { ghost: false }, cell: ({ row }) => , }, ]; diff --git a/apps/dashboard/src/components/data-table/table-cell-number.tsx b/apps/dashboard/src/components/data-table/table-cell-number.tsx index 77cfeb85c..5e564ab6f 100644 --- a/apps/dashboard/src/components/data-table/table-cell-number.tsx +++ b/apps/dashboard/src/components/data-table/table-cell-number.tsx @@ -22,7 +22,7 @@ export function TableCellNumber({ value, className, unit, - empty = "No data", + empty = "—", variant, ...props }: React.ComponentProps<"div"> & diff --git a/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx b/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx index e88b5a3c3..2082aea5b 100644 --- a/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx +++ b/apps/dashboard/src/components/forms/maintenance/sheet-create.tsx @@ -51,7 +51,10 @@ export function FormSheetMaintenanceCreate({ }) { const [internalOpen, setInternalOpen] = useState(false); const open = controlledOpen ?? internalOpen; - const setOpen = onOpenChange ?? setInternalOpen; + const setOpen = (next: boolean) => { + if (controlledOpen === undefined) setInternalOpen(next); + onOpenChange?.(next); + }; const [selectedPageId, setSelectedPageId] = useState(null); const trpc = useTRPC(); const queryClient = useQueryClient(); diff --git a/apps/dashboard/src/components/forms/maintenance/sheet.tsx b/apps/dashboard/src/components/forms/maintenance/sheet.tsx index f178a0a09..2596be883 100644 --- a/apps/dashboard/src/components/forms/maintenance/sheet.tsx +++ b/apps/dashboard/src/components/forms/maintenance/sheet.tsx @@ -25,19 +25,30 @@ export function FormSheetMaintenance({ defaultValues, onSubmit, items, + open: controlledOpen, + onOpenChange, ...props }: Omit, "onSubmit"> & { defaultValues?: FormValues; items: CheckboxTreeItem[]; onSubmit: (values: FormValues) => Promise; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = (next: boolean) => { + if (controlledOpen === undefined) setInternalOpen(next); + onOpenChange?.(next); + }; return ( - - {children} - + {children ? ( + + {children} + + ) : null} Maintenance diff --git a/apps/dashboard/src/components/forms/private-location/sheet-create.tsx b/apps/dashboard/src/components/forms/private-location/sheet-create.tsx new file mode 100644 index 000000000..51c98d3c2 --- /dev/null +++ b/apps/dashboard/src/components/forms/private-location/sheet-create.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { FormSheetPrivateLocation } from "@/components/forms/private-location/sheet"; +import { useTRPC } from "@/lib/trpc/client"; + +/** Create sheet with the mutation attached. `children` is the trigger. */ +export function FormSheetPrivateLocationCreate({ + children, + monitors, +}: { + children: React.ReactNode; + monitors: React.ComponentProps["monitors"]; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const createPrivateLocationMutation = useMutation( + trpc.privateLocation.new.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.privateLocation.list.queryKey(), + }); + }, + }), + ); + + return ( + { + await createPrivateLocationMutation.mutateAsync({ + name: values.name, + monitors: values.monitors, + token: values.token, + metadata: Object.fromEntries( + values.metadata.map((m) => [m.key, m.value]), + ), + }); + }} + > + {children} + + ); +} diff --git a/apps/dashboard/src/components/forms/settings/form-api-key.tsx b/apps/dashboard/src/components/forms/settings/form-api-key.tsx index ee60e6817..200264add 100644 --- a/apps/dashboard/src/components/forms/settings/form-api-key.tsx +++ b/apps/dashboard/src/components/forms/settings/form-api-key.tsx @@ -4,11 +4,6 @@ import { Button } from "@openstatus/ui/components/ui/button"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@/components/common/link"; -import { - EmptyStateDescription, - EmptyStateTitle, -} from "@/components/content/empty-state"; -import { EmptyStateContainer } from "@/components/content/empty-state"; import { DataTable } from "@/components/data-table/settings/api-key/data-table"; import { CreateApiKeyDialog } from "@/components/forms/api-key/dialog"; import { @@ -37,16 +32,7 @@ export function FormApiKey() { - {apiKeys.length === 0 ? ( - - No API keys - - Access your data via API. - - - ) : ( - - )} + diff --git a/apps/dashboard/src/components/forms/status-report/sheet-create.tsx b/apps/dashboard/src/components/forms/status-report/sheet-create.tsx index 5339c47bc..bd67ccc6d 100644 --- a/apps/dashboard/src/components/forms/status-report/sheet-create.tsx +++ b/apps/dashboard/src/components/forms/status-report/sheet-create.tsx @@ -54,7 +54,10 @@ export function FormSheetStatusReportCreate({ }) { const [internalOpen, setInternalOpen] = useState(false); const open = controlledOpen ?? internalOpen; - const setOpen = onOpenChange ?? setInternalOpen; + const setOpen = (next: boolean) => { + if (controlledOpen === undefined) setInternalOpen(next); + onOpenChange?.(next); + }; const [selectedPageId, setSelectedPageId] = useState(null); const trpc = useTRPC(); const queryClient = useQueryClient(); diff --git a/apps/dashboard/src/components/forms/status-report/sheet.tsx b/apps/dashboard/src/components/forms/status-report/sheet.tsx index 226d77045..5779561c2 100644 --- a/apps/dashboard/src/components/forms/status-report/sheet.tsx +++ b/apps/dashboard/src/components/forms/status-report/sheet.tsx @@ -26,17 +26,28 @@ export function FormSheetStatusReport({ onSubmit, items, warning, + open: controlledOpen, + onOpenChange, }: Omit, "onSubmit"> & { defaultValues?: FormValues; onSubmit: (values: FormValues) => Promise; items: CheckboxTreeItem[]; warning?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = (next: boolean) => { + if (controlledOpen === undefined) setInternalOpen(next); + onOpenChange?.(next); + }; return ( - {children} + {children ? ( + {children} + ) : null} Status Report diff --git a/apps/dashboard/src/components/metric/global-uptime/section.tsx b/apps/dashboard/src/components/metric/global-uptime/section.tsx index 7c91eac67..10c64d433 100644 --- a/apps/dashboard/src/components/metric/global-uptime/section.tsx +++ b/apps/dashboard/src/components/metric/global-uptime/section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import type { RouterOutputs } from "@openstatus/api"; import { formatDistanceToNow } from "date-fns"; import { @@ -12,13 +12,16 @@ import { MetricCardTitle, MetricCardValue, } from "@/components/metric/metric-card"; -import { type PERIODS, mapMetrics, metricsCards } from "@/data/metrics.client"; +import { + hasMetricsData, + mapMetrics, + metricsCards, +} from "@/data/metrics.client"; import { formatMilliseconds, formatNumber, formatPercentage, } from "@/lib/formatter"; -import { useTRPC } from "@/lib/trpc/client"; type Metric = { label: string; @@ -27,31 +30,15 @@ type Metric = { variant: React.ComponentProps["variant"]; }; -// TODO: move the fetch to the parent component // TODO: missing dynamic degraded export function GlobalUptimeSection({ - monitorId, - jobType, - period = "7d", - regions, + metrics, + isLoading, }: { - monitorId: string; - jobType: "http" | "tcp" | "dns" | "icmp" | "grpc"; - period: (typeof PERIODS)[number]; - regions: string[] | undefined; + metrics: RouterOutputs["tinybird"]["metrics"] | undefined; + isLoading: boolean; }) { - const trpc = useTRPC(); - - const { data: metrics, isLoading } = useQuery( - trpc.tinybird.metrics.queryOptions({ - monitorId, - period, - type: jobType, - regions, - }), - ); - // Helper to transform the data the same way it used to be in the page function defineMetrics() { if (!metrics) return null; @@ -70,7 +57,7 @@ export function GlobalUptimeSection({ const k = key as keyof typeof acc; const v = (() => { if (k === "lastTimestamp") { - if (!value) return "N/A"; + if (!value) return "—"; return formatDistanceToNow(new Date(value ?? 0), { addSuffix: true, }); @@ -123,6 +110,10 @@ export function GlobalUptimeSection({ ); } + const definedMetrics = defineMetrics(); + // a window with no checks (or a failed fetch) has no uptime or latency + const noData = !metrics || !hasMetricsData(metrics); + const refinedMetrics: (Metric | null)[] = ( [ "uptime", @@ -139,7 +130,15 @@ export function GlobalUptimeSection({ ).map((key) => { if (!key) return null; const metric = - defineMetrics()?.[key as keyof ReturnType[number]]; + definedMetrics?.[key as keyof ReturnType[number]]; + if (noData) { + return { + label: metricsCards[key].label, + value: key === "total" ? "0" : "—", + trend: null, + variant: metricsCards[key].variant, + } as Metric; + } return { label: metricsCards[key].label, value: metric?.value ?? "0", diff --git a/apps/dashboard/src/components/ui/data-table/data-table-empty.tsx b/apps/dashboard/src/components/ui/data-table/data-table-empty.tsx new file mode 100644 index 000000000..1a4a58298 --- /dev/null +++ b/apps/dashboard/src/components/ui/data-table/data-table-empty.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { Filter } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; +import { TableCell, TableRow } from "@openstatus/ui/components/ui/table"; +import type { Table } from "@tanstack/react-table"; + +import { + EmptyStateActions, + EmptyStateContainer, + EmptyStateDescription, + EmptyStateMedia, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { cn } from "@/lib/utils"; + +// pill widths cycle per cell so the ghost rows read as rows, not a grid +const GHOST_WIDTHS = ["w-3/5", "w-2/5", "w-1/2", "w-1/3", "w-2/5"]; +const GHOST_ROWS = 5; +// rows are h-10; the empty state pulls itself up by this much +const GHOST_HEIGHT = `${GHOST_ROWS * 2.5}rem`; + +/** Real table rows, so the ghost cells share the header column widths. */ +export function DataTableGhostRows({ table }: { table: Table }) { + const columns = table.getVisibleLeafColumns(); + // TanStack fills in a header for every column; only labeled ones get a pill. + // Internal API: if it ever goes away, unlabeled columns merely get a pill too. + const defaultHeader = table._getDefaultColumnDef?.().header; + const labeled = (column: (typeof columns)[number]) => + column.columnDef.meta?.ghost !== false && + column.columnDef.header !== defaultHeader && + column.columnDef.header !== ""; + return Array.from({ length: GHOST_ROWS }).map((_, row) => ( + + {columns.map((column, cell) => ( + + {labeled(column) ? ( + + ) : null} + + ))} + + )); +} + +/** First-run body, pulled up over the ghost rows the table renders. */ +export function DataTableEmpty({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ + {children} + +
+ ); +} + +/** Rows exist but the filters match none of them. */ +export function DataTableEmptyFiltered({ + table, +}: { + table: Table; +}) { + return ( + + + + + No matches + + Nothing matches the current filters. + + + + + + ); +} diff --git a/apps/dashboard/src/components/ui/data-table/data-table.tsx b/apps/dashboard/src/components/ui/data-table/data-table.tsx index 715a365b5..849d1378c 100644 --- a/apps/dashboard/src/components/ui/data-table/data-table.tsx +++ b/apps/dashboard/src/components/ui/data-table/data-table.tsx @@ -1,5 +1,6 @@ "use client"; +import { Inbox } from "@openstatus/icons"; import { Table, TableBody, @@ -28,7 +29,18 @@ import { import * as React from "react"; import { Fragment } from "react"; +import { + EmptyStateDescription, + EmptyStateMedia, + EmptyStateTitle, +} from "@/components/content/empty-state"; + import type { DataTableActionBarProps } from "./data-table-action-bar"; +import { + DataTableEmpty, + DataTableEmptyFiltered, + DataTableGhostRows, +} from "./data-table-empty"; import type { DataTablePaginationProps } from "./data-table-pagination"; import type { DataTableToolbarProps } from "./data-table-toobar"; @@ -39,6 +51,8 @@ export interface DataTableProps { toolbarComponent?: React.ComponentType>; actionBar?: React.ComponentType>; paginationComponent?: React.ComponentType>; + /** First-run body when `data` is empty, a `DataTableEmpty` so it covers the ghost rows; a filter mismatch has its own row. */ + emptyState?: React.ReactNode; onRowClick?: (row: Row) => void; defaultSorting?: SortingState; defaultColumnVisibility?: VisibilityState; @@ -64,6 +78,7 @@ export function DataTable({ toolbarComponent, actionBar, paginationComponent, + emptyState, onRowClick, defaultSorting = [], defaultColumnVisibility = {}, @@ -138,7 +153,7 @@ export function DataTable({ {toolbarComponent ? React.createElement(toolbarComponent, { table }) : null} -
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -204,14 +219,36 @@ export function DataTable({ )) ) : ( - - - No results. - - + <> + {data.length === 0 ? ( + + ) : null} + + + {/* the cell spans the scrollable table; pin to the visible width */} +
+ {data.length > 0 ? ( + + ) : ( + (emptyState ?? ( + + + + + Nothing here yet + + Entries show up here as soon as there are some. + + + )) + )} +
+
+
+ )} {actionBar ? React.createElement(actionBar, { table }) : null} diff --git a/apps/dashboard/src/data/metrics.client.test.ts b/apps/dashboard/src/data/metrics.client.test.ts index b67291207..bb4c1c666 100644 --- a/apps/dashboard/src/data/metrics.client.test.ts +++ b/apps/dashboard/src/data/metrics.client.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { getMonitorListMetrics, getRegionAxisMax, + hasMetricsData, isPaidPeriod, mapMetrics, mapRegionMetrics, @@ -55,6 +56,37 @@ describe("mapMetrics", () => { }); }); +describe("hasMetricsData", () => { + function window(lastTimestamp: number | null) { + return { + p50Latency: 0, + p75Latency: 0, + p90Latency: 0, + p95Latency: 0, + p99Latency: 0, + count: 0, + success: 0, + degraded: 0, + error: 0, + lastTimestamp, + }; + } + + it("is false when only the previous window row exists", () => { + expect(hasMetricsData({ data: [window(null)] } as unknown as Metrics)).toBe( + false, + ); + }); + + it("is true once the current window has a check", () => { + expect( + hasMetricsData({ + data: [window(null), window(123)], + } as unknown as Metrics), + ).toBe(true); + }); +}); + describe("mapRegionMetrics", () => { function row(region: string, timestamp: number, p50: number, p99: number) { return { @@ -175,8 +207,8 @@ describe("getMonitorListMetrics", () => { ]); }); - it("shows N/A without latency data", () => { + it("shows an em dash without latency data", () => { const p95 = getMonitorListMetrics().find((m) => m.key === "p95"); - expect(p95?.value).toBe("N/A"); + expect(p95?.value).toBe("—"); }); }); diff --git a/apps/dashboard/src/data/metrics.client.ts b/apps/dashboard/src/data/metrics.client.ts index 99e413dce..c6075e65e 100644 --- a/apps/dashboard/src/data/metrics.client.ts +++ b/apps/dashboard/src/data/metrics.client.ts @@ -57,6 +57,12 @@ export function mapMetrics(metrics: RouterOutputs["tinybird"]["metrics"]) { }); } +/** The current row carries a lastTimestamp; the previous one never does. */ +/** Only the current-window row carries a lastTimestamp; the previous-window row never does. */ +export function hasMetricsData(metrics: RouterOutputs["tinybird"]["metrics"]) { + return (mapMetrics(metrics) ?? []).some((row) => !!row.lastTimestamp); +} + export const metricsCards = { uptime: { label: "UPTIME", @@ -284,16 +290,17 @@ export function getMonitorListMetrics( value = monitors.filter((m) => m.active === false).length; break; case "p95": - const p95 = data.sort((a, b) => b.p95Latency - a.p95Latency)[0] + const p95 = [...data].sort((a, b) => b.p95Latency - a.p95Latency)[0] ?.p95Latency; - value = p95 ? formatMilliseconds(p95) : "N/A"; + value = p95 != null ? formatMilliseconds(p95) : "—"; break; } return { title: metricsGlobalCards[key].title, key, - value, + // a count of nothing is not a zero + value: monitors.length === 0 ? "—" : value, variant: variantMap[key], } as const; }) as readonly MonitorListMetric[]; diff --git a/apps/dashboard/src/lib/react-table.d.ts b/apps/dashboard/src/lib/react-table.d.ts index 6fd1ac4fb..f1ce3d3cc 100644 --- a/apps/dashboard/src/lib/react-table.d.ts +++ b/apps/dashboard/src/lib/react-table.d.ts @@ -11,6 +11,8 @@ declare module "@tanstack/react-table" { cellClassName?: string; label?: string; kind?: string; + /** `false` leaves the cell blank in the first-run ghost rows */ + ghost?: boolean; } interface FilterFns { diff --git a/packages/icons/src/lucide/index.ts b/packages/icons/src/lucide/index.ts index 11202d790..d50fdb6aa 100644 --- a/packages/icons/src/lucide/index.ts +++ b/packages/icons/src/lucide/index.ts @@ -83,6 +83,7 @@ export { Bell as Notification, BellOff as NotificationOff, LayoutGrid as Overview, + Pause, CircleDashed as Pending, Play, Plug, diff --git a/packages/icons/src/nucleo/index.ts b/packages/icons/src/nucleo/index.ts index dd2081716..f8d109b62 100644 --- a/packages/icons/src/nucleo/index.ts +++ b/packages/icons/src/nucleo/index.ts @@ -82,6 +82,7 @@ export { Next } from "./next"; export { Notification } from "./notification"; export { NotificationOff } from "./notification-off"; export { Overview } from "./overview"; +export { Pause } from "./pause"; export { Pending } from "./pending"; export { Play } from "./play"; export { Plug } from "./plug"; diff --git a/packages/icons/src/nucleo/pause.tsx b/packages/icons/src/nucleo/pause.tsx new file mode 100644 index 000000000..be57e8d81 --- /dev/null +++ b/packages/icons/src/nucleo/pause.tsx @@ -0,0 +1,46 @@ +import { forwardRef } from "react"; + +import type { IconProps } from "../types"; + +// from nucleo-ui-outline-18 (MediaPause); 18px grid, so the 1.5 stroke renders as 2px at size 24 +export const Pause = forwardRef( + ({ size = 24, strokeWidth = 1.5, ...props }, ref) => ( + + + + + ), +); +Pause.displayName = "Pause"; -- 2.54.0