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";