diff --git a/.gitignore b/.gitignore index 25fa87876..fe9bfebab 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ openstatus-dev.db-shm apps/ingest-worker/.production.vars apps/ingest-worker/.dev.vars apps/alerting-engine/tmp/ +.env*.local diff --git a/apps/dashboard/.gitignore b/apps/dashboard/.gitignore new file mode 100644 index 000000000..e985853ed --- /dev/null +++ b/apps/dashboard/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/apps/dashboard/components.json b/apps/dashboard/components.json new file mode 100644 index 000000000..3289f237a --- /dev/null +++ b/apps/dashboard/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts new file mode 100644 index 000000000..1b3be0840 --- /dev/null +++ b/apps/dashboard/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/dashboard/next.config.ts b/apps/dashboard/next.config.ts new file mode 100644 index 000000000..113a50310 --- /dev/null +++ b/apps/dashboard/next.config.ts @@ -0,0 +1,17 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + images: { + remotePatterns: [ + new URL("https://openstatus.dev/**"), + new URL("https://**.public.blob.vercel-storage.com/**"), + ], + }, + logging: { + fetches: { + fullUrl: true, + }, + }, +}; + +export default nextConfig; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json new file mode 100644 index 000000000..253b19eb2 --- /dev/null +++ b/apps/dashboard/package.json @@ -0,0 +1,109 @@ +{ + "name": "@openstatus/dashboard", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "next lint", + "tsc": "tsc --noEmit" + }, + "dependencies": { + "@auth/core": "0.40.0", + "@auth/drizzle-adapter": "1.10.0", + "@date-fns/tz": "1.2.0", + "@date-fns/utc": "2.1.0", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/modifiers": "9.0.0", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", + "@hookform/devtools": "4.4.0", + "@hookform/resolvers": "3.9.1", + "@libsql/client": "0.15.9", + "@openpanel/nextjs": "1.0.8", + "@openstatus/analytics": "workspace:*", + "@openstatus/api": "workspace:*", + "@openstatus/assertions": "workspace:*", + "@openstatus/db": "workspace:*", + "@openstatus/emails": "workspace:*", + "@openstatus/error": "workspace:*", + "@openstatus/header-analysis": "workspace:*", + "@openstatus/notification-discord": "workspace:*", + "@openstatus/notification-emails": "workspace:*", + "@openstatus/notification-ntfy": "workspace:*", + "@openstatus/notification-opsgenie": "workspace:*", + "@openstatus/notification-pagerduty": "workspace:*", + "@openstatus/notification-slack": "workspace:*", + "@openstatus/notification-webhook": "workspace:*", + "@openstatus/react": "workspace:*", + "@openstatus/tinybird": "workspace:*", + "@openstatus/tracker": "workspace:*", + "@openstatus/upstash": "workspace:*", + "@openstatus/utils": "workspace:*", + "@radix-ui/react-alert-dialog": "1.1.14", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.2", + "@radix-ui/react-collapsible": "1.1.11", + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-dropdown-menu": "2.1.15", + "@radix-ui/react-hover-card": "1.1.14", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-popover": "1.1.14", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.7", + "@radix-ui/react-select": "2.2.5", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.5", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.5", + "@radix-ui/react-tabs": "1.1.12", + "@radix-ui/react-tooltip": "1.2.7", + "@sentry/nextjs": "8.46.0", + "@stripe/stripe-js": "2.1.6", + "@tanstack/react-query": "5.81.5", + "@tanstack/react-table": "8.21.3", + "@trpc/client": "11.4.3", + "@trpc/next": "11.4.3", + "@trpc/react-query": "11.4.3", + "@trpc/server": "11.4.3", + "@trpc/tanstack-react-query": "11.4.3", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "cmdk": "1.1.1", + "date-fns": "4.1.0", + "lucide-react": "0.525.0", + "next": "15.3.4", + "next-auth": "5.0.0-beta.28", + "next-themes": "0.4.6", + "nuqs": "2.4.3", + "random-word-slugs": "0.1.7", + "react": "19.1.0", + "react-day-picker": "8.10.1", + "react-dom": "19.1.0", + "react-hook-form": "7.54.1", + "recharts": "2.15.0", + "rehype-react": "8.0.0", + "remark-gfm": "4.0.1", + "remark-parse": "11.0.0", + "remark-rehype": "11.1.2", + "sonner": "2.0.5", + "stripe": "13.8.0", + "superjson": "2.2.2", + "tailwind-merge": "3.3.1", + "unified": "11.0.5", + "zod": "3.24.1" + }, + "devDependencies": { + "@tailwindcss/postcss": "4.1.11", + "@types/dom-speech-recognition": "0.0.6", + "@types/node": "24.0.8", + "@types/react": "19.1.6", + "@types/react-dom": "19.1.6", + "shadcn": "2.7.0", + "tailwindcss": "4.1.11", + "tw-animate-css": "1.3.4", + "typescript": "5.7.2" + } +} diff --git a/apps/dashboard/postcss.config.mjs b/apps/dashboard/postcss.config.mjs new file mode 100644 index 000000000..c7bcb4b1e --- /dev/null +++ b/apps/dashboard/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/apps/dashboard/public/file.svg b/apps/dashboard/public/file.svg new file mode 100644 index 000000000..004145cdd --- /dev/null +++ b/apps/dashboard/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/dashboard/public/fonts/CalSans-SemiBold.ttf b/apps/dashboard/public/fonts/CalSans-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..4a2950a0451f66f8d3d77e466ce2fd3fe0e507b6 GIT binary patch literal 148964 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 I(U5gp0L_cM6951J literal 0 HcmV?d00001 diff --git a/apps/dashboard/public/globe.svg b/apps/dashboard/public/globe.svg new file mode 100644 index 000000000..567f17b0d --- /dev/null +++ b/apps/dashboard/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/dashboard/public/next.svg b/apps/dashboard/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/apps/dashboard/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/dashboard/public/vercel.svg b/apps/dashboard/public/vercel.svg new file mode 100644 index 000000000..770539603 --- /dev/null +++ b/apps/dashboard/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/dashboard/public/window.svg b/apps/dashboard/public/window.svg new file mode 100644 index 000000000..b2b2a44f6 --- /dev/null +++ b/apps/dashboard/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/dashboard/sentry.client.config.ts b/apps/dashboard/sentry.client.config.ts new file mode 100644 index 000000000..c30964939 --- /dev/null +++ b/apps/dashboard/sentry.client.config.ts @@ -0,0 +1,27 @@ +// This file configures the initialization of Sentry on the client. +// The config you add here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 0.5, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + + replaysOnErrorSampleRate: 1.0, + + // This sets the sample rate to be 10%. You may want this to be 100% while + // in development and sample at a lower rate in production + replaysSessionSampleRate: 0.1, + + // You can remove this option if you're not planning to use the Sentry Session Replay feature: + integrations: [ + Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }), + Sentry.captureConsoleIntegration({ levels: ["error"] }), + ], +}); diff --git a/apps/dashboard/sentry.edge.config.ts b/apps/dashboard/sentry.edge.config.ts new file mode 100644 index 000000000..0d4fd1acb --- /dev/null +++ b/apps/dashboard/sentry.edge.config.ts @@ -0,0 +1,16 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 0, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + integrations: [Sentry.captureConsoleIntegration({ levels: ["error"] })], +}); diff --git a/apps/dashboard/sentry.server.config.ts b/apps/dashboard/sentry.server.config.ts new file mode 100644 index 000000000..b0e1adde7 --- /dev/null +++ b/apps/dashboard/sentry.server.config.ts @@ -0,0 +1,16 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from "@sentry/nextjs"; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + // Adjust this value in production, or use tracesSampler for greater control + tracesSampleRate: 0.2, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, + integrations: [Sentry.captureConsoleIntegration({ levels: ["error"] })], +}); diff --git a/apps/dashboard/src/app/(dashboard)/cli/layout.tsx b/apps/dashboard/src/app/(dashboard)/cli/layout.tsx new file mode 100644 index 000000000..f2f89e5d1 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/cli/layout.tsx @@ -0,0 +1,26 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/cli/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/cli/nav-actions.tsx new file mode 100644 index 000000000..1faf18959 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/cli/nav-actions.tsx @@ -0,0 +1,33 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Book } from "lucide-react"; + +export function NavActions() { + return ( +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/cli/page.tsx b/apps/dashboard/src/app/(dashboard)/cli/page.tsx new file mode 100644 index 000000000..8f9a10de2 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/cli/page.tsx @@ -0,0 +1,242 @@ +import { Code } from "@/components/common/code"; +import { Link } from "@/components/common/link"; +import { + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { Section } from "@/components/content/section"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { FileDown, FileJson, Key, Terminal } from "lucide-react"; +import React from "react"; + +const OS = ["macOs", "Windows", "Linux"] as const; + +const installs = [ + { + title: "Install CLI", + icon: Terminal, + description: + "Install the OpenStatus CLI to set up your monitors straight in your code.", + command: { + macOs: [ + "brew install openstatusHQ/cli/openstatus --cask", + "curl -sSL instl.sh/openstatushq/cli/macos | bash", + ], + Linux: ["curl -sSL instl.sh/openstatushq/cli/linux | bash"], + Windows: ["iwr instl.sh/openstatushq/cli/windows | iex"], + }, + }, + { + title: "Add API Key", + icon: Key, + description: ( + <> + Create an API key in your workspace{" "} + settings. + + ), + command: { + macOs: ["export OPENSTATUS_API_TOKEN="], + Windows: ["set OPENSTATUS_API_TOKEN="], + Linux: ["export OPENSTATUS_API_TOKEN="], + }, + }, + { + title: "Import Monitors", + icon: FileDown, + description: "Import monitors from your workspace to a YAML file.", + command: "openstatus monitors import", + }, + { + title: "Manage Monitors", + icon: FileJson, + description: + "Add, remove, or update monitors from a YAML file and apply your changes.", + command: "openstatus monitors apply", + }, +] satisfies { + title: string; + icon: React.ElementType; + description: React.ReactNode; + command: string | Record<(typeof OS)[number], string[]>; +}[]; + +const commands = [ + { + command: "openstatus monitors list [options]", + description: "List all monitors in your workspace.", + }, + { + command: "openstatus monitors info [monitor-id] [options]", + description: "Get information about a specific monitor.", + }, + { + command: "openstatus monitors trigger [monitor-id] [options]", + description: "Trigger a monitor.", + }, + { + command: "openstatus run [options]", + description: "Run a list of monitors.", + }, +]; + +const templates = [ + { + description: "MCP server", + template: `# yaml-language-server: $schema=https://www.openstatus.dev/schema.json + +mcp-server: + name: "HF MCP Server" + description: "Hugging Face MCP server monitoring" + frequency: "1m" + active: true + regions: ["iad", "ams", "lax"] + retry: 3 + kind: http + request: + url: https://hf.co/mcp + method: POST + body: > + { + "jsonrpc": "2.0", + "id": "openstatus", + "method": "ping" + } + headers: + User-Agent: OpenStatus + Accept: application/json, text/event-stream + Content-Type: application/json + assertions: + - kind: statusCode + compare: eq + target: 200 + - kind: textBody + compare: eq + target: '{"result":{},"jsonrpc":"2.0","id":"openstatus"}' +`, + }, +]; + +export default function Page() { + return ( + +
+ + CLI + + Get started with the CLI to export and manage your monitors in your + code.{" "} + + Read more + + . + + + + + {OS.map((os) => ( + + {os} + + ))} + + {OS.map((os) => ( + + {installs.map((step, i) => { + const commands = + typeof step.command === "string" + ? step.command + : step.command[os]; + return ( +
+
+

+ + {step.title} +

+

+ {step.description} +

+
+ {typeof commands === "string" ? ( + {commands} + ) : ( + <> + {commands.map((command, i) => ( + + {command} + {i < commands.length - 1 && ( + or + )} + + ))} + + )} +
+ ); + })} +
+ ))} +
+
+
+ + Commands + + We have a few more commands to run. Check the{" "} + + documentation + {" "} + to read more. + + +
    + {commands.map((command, i) => ( +
  • +

    + {command.description} +

    + {command.command} +
  • + ))} +
+
+
+ + Templates + + We have a few templates to help you get started. Check the{" "} + + @openstatusHQ/cli-template + {" "} + repository for more. + + +
+ {templates.map((template, i) => ( +
+

+ {template.description} +

+ {template.template} +
+ ))} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/invite/client.tsx b/apps/dashboard/src/app/(dashboard)/invite/client.tsx new file mode 100644 index 000000000..f28d7c8c3 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/invite/client.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { Button } from "@/components/ui/button"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isTRPCClientError } from "@trpc/client"; +import { useQueryStates } from "nuqs"; +import { useTransition } from "react"; +import { toast } from "sonner"; +import { searchParamsParsers } from "./search-params"; + +export function Client() { + const trpc = useTRPC(); + const [isPending, startTransition] = useTransition(); + const [{ token }] = useQueryStates(searchParamsParsers); + const { data: invitation, error } = useQuery({ + ...trpc.invitation.get.queryOptions({ token }), + retry: false, + }); + const acceptInvitationMutation = useMutation( + trpc.invitation.accept.mutationOptions({ + onSuccess: (workspace) => { + if (!workspace) return; + document.cookie = `workspace-slug=${workspace.slug}; path=/;`; + window.location.href = "/overview"; + }, + }), + ); + + // TODO: check if we can have a high level wrapper for isTRPCClientError errors + if (isTRPCClientError(error)) { + return ( + +
+ + Error + + {error.message} + + +
+
+ ); + } + + if (!invitation) return null; + if (invitation.acceptedAt) return null; + + return ( + +
+ + Invitation + + You've been invited to join the workspace + {invitation.workspace.name ? ( + {` ${invitation.workspace.name}`} + ) : ( + "" + )} + . + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/invite/layout.tsx b/apps/dashboard/src/app/(dashboard)/invite/layout.tsx new file mode 100644 index 000000000..23734266d --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/invite/layout.tsx @@ -0,0 +1,25 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/invite/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/invite/nav-actions.tsx new file mode 100644 index 000000000..768c5c97f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/invite/nav-actions.tsx @@ -0,0 +1,9 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; + +export function NavActions() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/invite/page.tsx b/apps/dashboard/src/app/(dashboard)/invite/page.tsx new file mode 100644 index 000000000..d1e38078a --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/invite/page.tsx @@ -0,0 +1,24 @@ +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { redirect } from "next/navigation"; +import type { SearchParams } from "nuqs"; +import { Client } from "./client"; +import { searchParamsCache } from "./search-params"; + +export default async function InvitePage(props: { + searchParams: Promise; +}) { + const { token } = await searchParamsCache.parse(props.searchParams); + + if (!token) { + return redirect("/overview"); + } + + const queryClient = getQueryClient(); + await queryClient.prefetchQuery(trpc.invitation.get.queryOptions({ token })); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/invite/search-params.tsx b/apps/dashboard/src/app/(dashboard)/invite/search-params.tsx new file mode 100644 index 000000000..9f29abbef --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/invite/search-params.tsx @@ -0,0 +1,7 @@ +import { createSearchParamsCache, parseAsString } from "nuqs/server"; + +export const searchParamsParsers = { + token: parseAsString, +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/(dashboard)/layout.tsx b/apps/dashboard/src/app/(dashboard)/layout.tsx new file mode 100644 index 000000000..c418d08c6 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/layout.tsx @@ -0,0 +1,43 @@ +import { AppSidebar } from "@/components/nav/app-sidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { auth } from "@/lib/auth"; +import { TRPCReactProvider } from "@/lib/trpc/client"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { SessionProvider } from "next-auth/react"; +import { cookies } from "next/headers"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth(); + const cookieStore = await cookies(); + const hasState = cookieStore.has("sidebar_state"); + const defaultOpen = hasState + ? cookieStore.get("sidebar_state")?.value === "true" + : true; + + return ( + + + + + + {children} + + + + + ); +} + +async function HydrateSidebar({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + await queryClient.prefetchQuery(trpc.page.list.queryOptions()); + await queryClient.prefetchQuery(trpc.monitor.list.queryOptions()); + await queryClient.prefetchQuery(trpc.workspace.get.queryOptions()); + await queryClient.prefetchQuery(trpc.user.get.queryOptions()); + + return {children}; +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/monitors/(list)/breadcrumb.tsx new file mode 100644 index 000000000..34e419b40 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/breadcrumb.tsx @@ -0,0 +1,5 @@ +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; + +export function Breadcrumb() { + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx new file mode 100644 index 000000000..7ba47b3a9 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/client.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { columns } from "@/components/data-table/monitors/columns"; +import { MonitorDataTableActionBar } from "@/components/data-table/monitors/data-table-action-bar"; +import { MonitorDataTableToolbar } from "@/components/data-table/monitors/data-table-toolbar"; +import { + MetricCardButton, + MetricCardGroup, + MetricCardHeader, + MetricCardSkeleton, + MetricCardTitle, + MetricCardValue, +} from "@/components/metric/metric-card"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; +import { getMonitorListMetrics } from "@/data/metrics.client"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import type { ColumnFiltersState, SortingState } from "@tanstack/react-table"; +import { ArrowDown, CheckCircle, ListFilter } from "lucide-react"; +import { useQueryStates } from "nuqs"; +import { useEffect, useState } from "react"; +import { searchParamsParsers } from "./search-params"; + +const icons = { + default: { + active: CheckCircle, + inactive: ListFilter, + }, + p95: { + active: ArrowDown, + inactive: ListFilter, + }, +} as const; + +export function Client() { + const trpc = useTRPC(); + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); + const { data: tags } = useQuery(trpc.monitorTag.list.queryOptions()); + const [searchParams, setSearchParams] = useQueryStates(searchParamsParsers); + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + + const monitorsByType = { + http: + monitors + ?.filter((m) => m.jobType === "http") + .map((m) => m.id.toString()) ?? [], + tcp: + monitors + ?.filter((m) => m.jobType === "tcp") + .map((m) => m.id.toString()) ?? [], + }; + const { http: httpMonitors, tcp: tcpMonitors } = monitorsByType; + + // HMM: why do we need two queries? + const { data: globalHttpMetrics, isLoading: isLoadingHttp } = useQuery({ + ...trpc.tinybird.globalMetrics.queryOptions({ + monitorIds: httpMonitors, + type: "http", + }), + enabled: httpMonitors.length > 0, + }); + + const { data: globalTcpMetrics, isLoading: isLoadingTcp } = useQuery({ + ...trpc.tinybird.globalMetrics.queryOptions({ + monitorIds: tcpMonitors, + type: "tcp", + }), + enabled: tcpMonitors.length > 0, + }); + + // TODO: ideally we read from the searchParamsCache and there is no layout shift + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (searchParams.status) { + setColumnFilters([{ id: "status", value: [searchParams.status] }]); + } + if (searchParams.sort) { + setSorting([searchParams.sort]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!monitors) return null; + + const metrics = getMonitorListMetrics(monitors, [ + ...(globalHttpMetrics?.data ?? []), + ...(globalTcpMetrics?.data ?? []), + ]); + + return ( + +
+ + Monitors + + Create and manage your monitors. + + + + {metrics.map((metric) => { + const statusArray = columnFilters.find((f) => f.id === "status") + ?.value as string[] | undefined; + + let isActive = false; + if (metric.key === "p95") { + isActive = !!sorting.find((s) => s.id === "p95" && s.desc); + } else { + isActive = + Array.isArray(statusArray) && statusArray.includes(metric.key); + } + + const iconGroup = metric.key === "p95" ? icons.p95 : icons.default; + const Icon = iconGroup[isActive ? "active" : "inactive"]; + + return ( + { + 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([]); + } + } else { + if (columnFilters.length === 0 || !isActive) { + setSearchParams({ status: metric.key }); + setColumnFilters([{ id: "status", value: [metric.key] }]); + } else { + setSearchParams({ status: null }); + setColumnFilters([]); + } + } + }} + > + + + {metric.title} + + + + {metric.key === "p95" && (isLoadingHttp || isLoadingTcp) ? ( + + ) : ( + {metric.value} + )} + + ); + })} + +
+
+ ({ + ...monitor, + globalMetrics: + isLoadingHttp || isLoadingTcp + ? undefined + : monitor.jobType === "http" + ? globalHttpMetrics?.data?.find( + (m) => m.monitorId === monitor.id.toString(), + ) ?? false + : globalTcpMetrics?.data?.find( + (m) => m.monitorId === monitor.id.toString(), + ) ?? false, + }))} + actionBar={MonitorDataTableActionBar} + toolbarComponent={(props) => ( + + )} + paginationComponent={DataTablePaginationSimple} + columnFilters={columnFilters} + setColumnFilters={setColumnFilters} + sorting={sorting} + setSorting={setSorting} + defaultColumnVisibility={{ + active: false, + url: false, + jobType: false, + }} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/(list)/layout.tsx new file mode 100644 index 000000000..29afe1b3d --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/layout.tsx @@ -0,0 +1,25 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; +import { Breadcrumb } from "./breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/monitors/(list)/nav-actions.tsx new file mode 100644 index 000000000..f20ad743f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/nav-actions.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { UpgradeDialog } from "@/components/dialogs/upgrade"; +import { NavFeedback } from "@/components/nav/nav-feedback"; +import { Button } from "@/components/ui/button"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useState } from "react"; + +export function NavActions() { + const trpc = useTRPC(); + const [openDialog, setOpenDialog] = useState(false); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); + + if (!workspace || !monitors) return null; + + const limitReached = monitors.length >= workspace.limits.monitors; + + return ( +
+ + {limitReached ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/(list)/page.tsx new file mode 100644 index 000000000..a22b1904d --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/page.tsx @@ -0,0 +1,22 @@ +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import type { SearchParams } from "nuqs"; +import { Client } from "./client"; +import { searchParamsCache } from "./search-params"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise; +}) { + const queryClient = getQueryClient(); + + await searchParamsCache.parse(searchParams); + await queryClient.prefetchQuery(trpc.monitor.list.queryOptions()); + await queryClient.prefetchQuery(trpc.monitorTag.list.queryOptions()); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/(list)/search-params.ts b/apps/dashboard/src/app/(dashboard)/monitors/(list)/search-params.ts new file mode 100644 index 000000000..4843b0ddf --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/(list)/search-params.ts @@ -0,0 +1,23 @@ +import { + createParser, + createSearchParamsCache, + parseAsStringEnum, +} from "nuqs/server"; + +export const parseAsSort = createParser({ + parse(queryValue) { + const [id, desc] = queryValue.split("."); + if (!id && !desc) return null; + return { id, desc: desc === "desc" }; + }, + serialize(value) { + return `${value.id}.${value.desc ? "desc" : "asc"}`; + }, +}); + +export const searchParamsParsers = { + status: parseAsStringEnum(["active", "degraded", "error", "inactive"]), + sort: parseAsSort, +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx new file mode 100644 index 000000000..d9dd73d1c --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/breadcrumb.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { Cog, LayoutGrid, Logs, Siren } from "lucide-react"; +import { useParams } from "next/navigation"; + +export function Breadcrumb() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const { data: monitor } = useQuery( + trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), + ); + + if (!monitor) return null; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/layout.tsx new file mode 100644 index 000000000..34360a88b --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/layout.tsx @@ -0,0 +1,12 @@ +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const queryClient = getQueryClient(); + await queryClient.prefetchQuery(trpc.monitorTag.list.queryOptions()); + + return {children}; +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/page.tsx new file mode 100644 index 000000000..98e2f66a9 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/edit/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { FormMonitorUpdate } from "@/components/forms/monitor/update"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +export default function Page() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const { data: monitor } = useQuery( + trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), + ); + + if (!monitor) return null; + + return ( + +
+ + {monitor.name} + Customize your monitor. + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx new file mode 100644 index 000000000..abc810d42 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/layout.tsx @@ -0,0 +1,13 @@ +import { SidebarProvider } from "@/components/ui/sidebar"; +import { Sidebar } from "../sidebar"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + +
{children}
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx new file mode 100644 index 000000000..c50e7afa2 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { columns } from "@/components/data-table/incidents/columns"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +export default function Page() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const { data: incidents } = useQuery( + trpc.incident.list.queryOptions({ + monitorId: Number.parseInt(id), + }), + ); + const { data: monitor } = useQuery( + trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), + ); + + if (!incidents || !monitor) return null; + + return ( + +
+ + {monitor.name} + + {monitor.jobType === "http" ? ( + + {monitor.url} + + ) : ( + monitor.url + )} + + + {incidents.length === 0 ? ( + + No incidents + + No incidents found for this monitor. + + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx new file mode 100644 index 000000000..b881d52cc --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/layout.tsx @@ -0,0 +1,43 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { Breadcrumb } from "./breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const queryClient = getQueryClient(); + + await queryClient.prefetchQuery( + trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), + ); + + await queryClient.prefetchQuery(trpc.notification.list.queryOptions()); + + return ( + +
+ + + + + + + + + +
{children}
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx new file mode 100644 index 000000000..52e75286e --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { Link } from "@/components/common/link"; +import { + BillingOverlay, + BillingOverlayButton, + BillingOverlayContainer, + BillingOverlayDescription, +} from "@/components/content/billing-overlay"; +import { + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; + +import { Section } from "@/components/content/section"; +import { ButtonReset } from "@/components/controls-search/button-reset"; +import { CommandRegion } from "@/components/controls-search/command-region"; +import { DropdownStatus } from "@/components/controls-search/dropdown-status"; +import { DropdownTrigger } from "@/components/controls-search/dropdown-trigger"; +import { PopoverDate } from "@/components/controls-search/popover-date"; +import { columns } from "@/components/data-table/response-logs/columns"; +import { Sheet } from "@/components/data-table/response-logs/data-table-sheet"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination"; +import { DataTableSkeleton } from "@/components/ui/data-table/data-table-skeleton"; +import { exampleLogs } from "@/data/response-logs"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import type { PaginationState } from "@tanstack/react-table"; +import { Lock } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useQueryStates } from "nuqs"; +import { useCallback, useMemo } from "react"; +import { searchParamsParsers } from "./search-params"; + +export function Client() { + const trpc = useTRPC(); + const { id } = useParams<{ id: string }>(); + const [ + { regions, status, selected, trigger, from, to, pageIndex, pageSize }, + setSearchParams, + ] = useQueryStates(searchParamsParsers); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: monitor } = useQuery( + trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), + ); + const enabled = workspace && workspace?.plan !== "free"; + const { data: _logs, isLoading } = useQuery({ + ...trpc.tinybird.list.queryOptions({ monitorId: id, from, to }), + enabled, + }); + const { data: _log } = useQuery({ + ...trpc.tinybird.get.queryOptions({ id: selected, monitorId: id }), + enabled: !!selected && enabled, + }); + + const pagination = useMemo( + () => ({ pageIndex, pageSize }), + [pageIndex, pageSize], + ); + + const setPagination = useCallback( + (p: PaginationState | ((old: PaginationState) => PaginationState)) => { + const next = typeof p === "function" ? p({ pageIndex, pageSize }) : p; + + if (next.pageIndex !== pageIndex || next.pageSize !== pageSize) { + setSearchParams({ + pageIndex: next.pageIndex, + pageSize: next.pageSize, + }); + } + }, + [pageIndex, pageSize, setSearchParams], + ); + + if (!workspace || !monitor) return null; + + return ( + +
+ + {monitor.name} + + {monitor.jobType === "http" ? ( + + {monitor.url} + + ) : ( + monitor.url + )} + + +
+ + {monitor.jobType === "http" ? : null} + + + +
+
+
+ {isLoading ? ( + + ) : !enabled ? ( + + ) : ( + { + if (!row.original.id) return; + setSearchParams({ selected: row.original.id }); + }} + columnFilters={[ + { id: "trigger", value: trigger }, + { id: "requestStatus", value: status }, + { id: "region", value: regions }, + ].filter((i) => Boolean(i.value))} + pagination={pagination} + setPagination={setPagination} + paginationComponent={DataTablePagination} + defaultColumnVisibility={ + monitor.jobType === "tcp" + ? { timing: false, statusCode: false } + : {} + } + // NOTE: required to control the pagination + autoResetPageIndex={false} + /> + )} + + setTimeout(() => setSearchParams({ selected: null }), 300) + } + /> +
+
+ ); +} + +function BillingPlaceholder() { + return ( + + + + + + + Upgrade + + + + Access response headers, timing phases and more for each request.{" "} + + Learn more + + . + + + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/page.tsx new file mode 100644 index 000000000..478df3192 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/page.tsx @@ -0,0 +1,14 @@ +import type { SearchParams } from "nuqs/server"; +import { Client } from "./client"; +import { searchParamsCache } from "./search-params"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise; +}) { + // NOTE: store in cache to avoid flicker on clients first render + await searchParamsCache.parse(searchParams); + + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/search-params.ts b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/search-params.ts new file mode 100644 index 000000000..73cd4f370 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/search-params.ts @@ -0,0 +1,29 @@ +import { PERIODS, STATUS, TRIGGER } from "@/data/metrics.client"; +import { flyRegions } from "@openstatus/db/src/schema/constants"; +import { endOfDay } from "date-fns"; +import { startOfDay } from "date-fns"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsIsoDateTime, + parseAsString, + parseAsStringLiteral, +} from "nuqs/server"; + +export const searchParamsParsers = { + period: parseAsStringLiteral(PERIODS).withDefault("1d"), + regions: parseAsArrayOf(parseAsStringLiteral(flyRegions)).withDefault( + // FIXME: readonly + flyRegions as unknown as (typeof flyRegions)[number][], + ), + status: parseAsStringLiteral(STATUS), + trigger: parseAsStringLiteral(TRIGGER), + selected: parseAsString, + from: parseAsIsoDateTime.withDefault(startOfDay(new Date())), + to: parseAsIsoDateTime.withDefault(endOfDay(new Date())), + pageIndex: parseAsInteger.withDefault(0), + pageSize: parseAsInteger.withDefault(20), +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx new file mode 100644 index 000000000..307dcceec --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { DataTableSheetTest } from "@/components/data-table/response-logs/data-table-sheet-test"; +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { NavFeedback } from "@/components/nav/nav-feedback"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { getActions } from "@/data/monitors.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { deserialize } from "@openstatus/assertions"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { isTRPCClientError } from "@trpc/client"; +import { Zap } from "lucide-react"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +type TestTCP = RouterOutputs["checker"]["testTcp"]; +type TestHTTP = RouterOutputs["checker"]["testHttp"]; + +export function NavActions() { + const { id } = useParams<{ id: string }>(); + const [test, setTest] = useState(null); + const queryClient = useQueryClient(); + const trpc = useTRPC(); + const router = useRouter(); + const pathname = usePathname(); + + const { data: monitor } = useQuery( + trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), + ); + + const deleteMonitorMutation = useMutation( + trpc.monitor.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.monitor.list.queryKey(), + }); + if (pathname.includes(`/monitors/${id}`)) { + router.push("/monitors"); + } + }, + }), + ); + + const cloneMonitorMutation = useMutation( + trpc.monitor.clone.mutationOptions({ + onSuccess: (newMonitor) => { + queryClient.invalidateQueries({ + queryKey: trpc.monitor.list.queryKey(), + }); + router.push(`/monitors/${newMonitor.id}`); + }, + }), + ); + + const testHttpMutation = useMutation(trpc.checker.testHttp.mutationOptions()); + const testTcpMutation = useMutation(trpc.checker.testTcp.mutationOptions()); + + const actions = getActions({ + edit: () => router.push(`/monitors/${id}/edit`), + "copy-id": () => { + navigator.clipboard.writeText("ID"); + toast.success("Monitor ID copied to clipboard"); + }, + clone: () => { + const promise = cloneMonitorMutation.mutateAsync({ + id: Number.parseInt(id), + }); + toast.promise(promise, { + loading: "Cloning monitor...", + success: "Monitor cloned", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "Failed to clone monitor"; + }, + }); + }, + }); + + async function testAction() { + if (monitor?.jobType === "http") { + const assertions = deserialize(monitor.assertions ?? "[]"); + const promise = testHttpMutation.mutateAsync({ + url: monitor.url, + body: monitor.body, + method: monitor.method, + headers: monitor.headers, + assertions: assertions.map((a) => a.schema), + }); + + toast.promise(promise, { + loading: "Testing HTTP request...", + success: (data) => { + setTest(data); + return "HTTP test completed successfully"; + }, + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "HTTP test failed"; + }, + }); + } else if (monitor?.jobType === "tcp") { + const promise = testTcpMutation.mutateAsync({ url: monitor.url }); + + toast.promise(promise, { + loading: "Testing TCP connection...", + success: (data) => { + setTest(data); + return "TCP test completed successfully"; + }, + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "TCP test failed"; + }, + }); + } + } + + if (!monitor) return null; + + return ( +
+ +
+ {!monitor.active ? ( + + + + ) : monitor.status === "active" ? ( + + + + + ) : monitor.status === "error" ? ( + + + + ) : ( + + + + )} +
+ + + + + + Test Monitor + + + { + await deleteMonitorMutation.mutateAsync({ + id: Number.parseInt(id), + }); + }, + }} + /> + { + await new Promise((resolve) => setTimeout(() => resolve(true), 300)); + setTest(null); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx new file mode 100644 index 000000000..43f0cf8a0 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx @@ -0,0 +1,167 @@ +"use client"; + +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 { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { ButtonReset } from "@/components/controls-search/button-reset"; +import { CommandRegion } from "@/components/controls-search/command-region"; +import { DropdownInterval } from "@/components/controls-search/dropdown-interval"; +import { DropdownPercentile } from "@/components/controls-search/dropdown-percentile"; +import { DropdownPeriod } from "@/components/controls-search/dropdown-period"; +import { AuditLogsWrapper } from "@/components/data-table/audit-logs/wrapper"; +import { columns as regionColumns } from "@/components/data-table/response-logs/regions/columns"; +import { GlobalUptimeSection } from "@/components/metric/global-uptime/section"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { mapRegionMetrics } from "@/data/metrics.client"; +import type { RegionMetric } from "@/data/region-metrics"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { useQueryStates } from "nuqs"; +import React from "react"; +import { searchParamsParsers } from "./search-params"; + +const TIMELINE_INTERVAL = 30; // in days + +export function Client() { + const trpc = useTRPC(); + const { id } = useParams<{ id: string }>(); + const [{ period, regions: selectedRegions, percentile, interval }] = + useQueryStates(searchParamsParsers); + const { data: monitor } = useQuery( + trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), + ); + + const regionTimelineQuery = { + ...trpc.tinybird.metricsRegions.queryOptions({ + monitorId: id, + period: period, + type: (monitor?.jobType ?? "http") as "http" | "tcp", + regions: selectedRegions, + // Request 30-minute buckets by default + interval: 30, + }), + enabled: !!monitor, + } as const; + + const { data: regionTimeline } = useQuery(regionTimelineQuery); + + const regionMetrics: RegionMetric[] = React.useMemo(() => { + return mapRegionMetrics(regionTimeline, monitor?.regions ?? []); + }, [regionTimeline, monitor]); + + if (!monitor) return null; + + return ( + +
+ + {monitor.name} + + {monitor.jobType === "http" ? ( + + {monitor.url} + + ) : ( + monitor.url + )} + + +
+
+ including{" "} + +
+
+ +
+
+ +
+
+ + Uptime + + Uptime accross all the regions + + + + selectedRegions.includes(region), + )} + /> +
+
+ {/* TODO: based on http, we have Timing Phases instead of Latency */} + + Latency + + Response time accross all the regions + + +
+
+ The quantile within a {" "} + resolution +
+
+ +
+
+ {monitor.jobType === "http" ? ( + + ) : ( + + )} +
+
+ + Timeline + + What happened to your monitor over the last {TIMELINE_INTERVAL} days + + + +
+
+ + Regions + + Every region's latency over the last 24 hours + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/layout.tsx new file mode 100644 index 000000000..2ea65b637 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/layout.tsx @@ -0,0 +1,14 @@ +import { SidebarProvider } from "@/components/ui/sidebar"; +import { Sidebar } from "../sidebar"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {/* blur-2xl */} +
{children}
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/page.tsx new file mode 100644 index 000000000..478df3192 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/page.tsx @@ -0,0 +1,14 @@ +import type { SearchParams } from "nuqs/server"; +import { Client } from "./client"; +import { searchParamsCache } from "./search-params"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise; +}) { + // NOTE: store in cache to avoid flicker on clients first render + await searchParamsCache.parse(searchParams); + + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/search-params.ts b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/search-params.ts new file mode 100644 index 000000000..257a0c917 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/search-params.ts @@ -0,0 +1,23 @@ +import { INTERVALS } from "@/data/metrics.client"; +import { flyRegions } from "@openstatus/db/src/schema/constants"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsNumberLiteral, + parseAsStringLiteral, +} from "nuqs/server"; + +const PERIOD = ["1d", "7d", "14d"] as const; +const PERCENTILE = ["p50", "p75", "p90", "p95", "p99"] as const; + +export const searchParamsParsers = { + period: parseAsStringLiteral(PERIOD).withDefault("1d"), + regions: parseAsArrayOf(parseAsStringLiteral(flyRegions)).withDefault( + // FIXME: readonly + flyRegions as unknown as (typeof flyRegions)[number][], + ), + percentile: parseAsStringLiteral(PERCENTILE).withDefault("p50"), + interval: parseAsNumberLiteral(INTERVALS).withDefault(30), +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/page.tsx new file mode 100644 index 000000000..51aad8513 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + redirect(`/monitors/${id}/overview`); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx new file mode 100644 index 000000000..d944c1587 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { TableCellLink } from "@/components/data-table/table-cell-link"; +import { SidebarRight } from "@/components/nav/sidebar-right"; +import { Badge } from "@/components/ui/badge"; +import { formatMilliseconds } from "@/lib/formatter"; +import { useTRPC } from "@/lib/trpc/client"; +import { deserialize } from "@openstatus/assertions"; +import { useQuery } from "@tanstack/react-query"; +import { Logs } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; + +export function Sidebar() { + const router = useRouter(); + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const { data: monitor } = useQuery( + trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), + ); + + if (!monitor) return null; + + const assertions = monitor.assertions ? deserialize(monitor.assertions) : []; + + return ( + Normal, + }, + { + label: "Type", + value: {monitor.jobType}, + }, + { + label: "Endpoint", + value: monitor.url.replace(/^https?:\/\//, ""), + }, + { + label: "Regions", + value: + monitor.regions.length > 6 + ? `${monitor.regions.length} regions` + : monitor.regions.join(", "), + }, + { + label: "Tags", + value: ( +
+ {monitor.tags.map((tag) => ( + +
+ {tag.name} + + ))} +
+ ), + }, + ], + }, + { + label: "Configuration", + items: [ + { label: "Periodicity", value: monitor.periodicity }, + { + label: "Timeout", + value: formatMilliseconds(monitor.timeout), + }, + { label: "Public", value: String(monitor.public) }, + { label: "Active", value: String(monitor.active) }, + ], + }, + { + label: "Notifications", + items: monitor.notifications.flatMap((notification) => { + const arr = []; + arr.push({ + label: "Name", + value: ( + + ), + }); + arr.push({ + label: "Type", + value: notification.provider, + isNested: true, + }); + arr.push({ + label: "Value", + value: notification.data, // TODO: improve this based on the provider - we might wanna parse it! + isNested: true, + }); + return arr; + }), + }, + { + label: "Assertions", + items: + assertions.length > 0 + ? assertions.flatMap((assertion) => { + const arr = []; + + arr.push({ + label: "Type", + value: assertion.schema.type, + }); + + arr.push({ + label: "Compare", + value: assertion.schema.compare, + isNested: true, + }); + + if ( + assertion.schema.type === "header" && + assertion.schema.key + ) { + arr.push({ + label: "Key", + value: assertion.schema.key, + isNested: true, + }); + } + + arr.push({ + label: "Value", + value: assertion.schema.target, + isNested: true, + }); + + return arr; + }) + : [], + }, + // { + // label: "Last Logs", + // items: [ + // ...Array.from({ length: 20 }).map((_, index) => { + // const date = new Date(new Date().getTime() - index * 500000); + // return { + // label: [ + // "Amsterdam", + // "Frankfurt", + // "New York", + // "Singapore", + // "Johannesburg", + // ][index % 5], + // value: ( + //
+ // + // + // + // + // + // {date.toLocaleTimeString("en-US", { + // hour: "2-digit", + // minute: "2-digit", + // })} + // + // + // + // {date.toLocaleString("en-US")} + // + // + // + //
+ // ), + // }; + // }), + // ], + // }, + ]} + footerButton={{ + onClick: () => router.push(`/monitors/${id}/logs`), + children: ( + <> + + View all logs + + ), + }} + /> + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/monitors/create/breadcrumb.tsx new file mode 100644 index 000000000..6fb6b93ab --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/breadcrumb.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; + +export function Breadcrumb() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/layout.tsx b/apps/dashboard/src/app/(dashboard)/monitors/create/layout.tsx new file mode 100644 index 000000000..0b429f218 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/layout.tsx @@ -0,0 +1,17 @@ +import { AppHeader, AppHeaderContent } from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; +import { Breadcrumb } from "./breadcrumb"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx new file mode 100644 index 000000000..4c6474293 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { + EmptyStateContainer, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { EmptyStateDescription } from "@/components/content/empty-state"; +import { + Section, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { FormGeneral } from "@/components/forms/monitor/form-general"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; + +export default function Page() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const router = useRouter(); + const createMonitorMutation = useMutation( + trpc.monitor.new.mutationOptions({ + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: trpc.monitor.list.queryKey(), + }); + router.push(`/monitors/${data.id}/edit`); + }, + }), + ); + + return ( + +
+ + Create Monitor + + { + await createMonitorMutation.mutateAsync({ + name: data.name, + jobType: data.type, + url: data.url, + method: data.method, + headers: data.headers, + body: data.body, + active: data.active, + assertions: data.assertions, + saveCheck: data.saveCheck, + skipCheck: data.skipCheck, + }); + }} + /> +
+
+ + Create and start customizing + + Change the periodicity, set + up the regions,{" "} + timeout or{" "} + degraded duration and + more... + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/notifications/client.tsx b/apps/dashboard/src/app/(dashboard)/notifications/client.tsx new file mode 100644 index 000000000..835645be7 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/notifications/client.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { Link } from "@/components/common/link"; +import { + ActionCard, + ActionCardDescription, + ActionCardHeader, + ActionCardTitle, +} from "@/components/content/action-card"; +import { ActionCardGroup } from "@/components/content/action-card"; +import { + EmptyStateContainer, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +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 { config } from "@/data/notifications.client"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useQueryStates } from "nuqs"; +import { searchParamsParsers } from "./search-params"; + +// FIXME: WARNING we are using the `web` api url here +const BASE_URL = + process.env.NODE_ENV === "development" + ? "http://localhost:3000" + : "https://www.openstatus.dev"; + +export function Client() { + const trpc = useTRPC(); + const { data: notifications, refetch } = useQuery( + trpc.notification.list.queryOptions(), + ); + const [searchParams] = useQueryStates(searchParamsParsers); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); + const createNotifierMutation = useMutation( + trpc.notification.new.mutationOptions({ + onSuccess: () => refetch(), + }), + ); + + if (!notifications || !monitors || !workspace) return null; + + const limitReached = + notifications.length >= workspace.limits["notification-channels"]; + + return ( + + + Notifications + +
+ {notifications.length === 0 ? ( + + No notifier found + + ) : ( + + )} +
+
+ + Create a new notifier + + Define your notifications to receive alerts when downtime occurs.{" "} + + Learn more + + . + + + + {Object.keys(config).map((notifier) => { + const key = notifier as keyof typeof config; + const Icon = config[key].icon; + let enabled = true; + + if (key in workspace.limits) { + enabled = + workspace.limits[key as "opsgenie" | "sms" | "opsgenie"]; + } + + if (limitReached) { + enabled = false; + } + + if (!searchParams.channel && key === "pagerduty") { + const PAGERDUTY_URL = `https://app.pagerduty.com/install/integration?app_id=${process.env.NEXT_PUBLIC_PAGERDUTY_APP_ID}&redirect_url=${BASE_URL}/api/callback/pagerduty?workspace=${workspace.slug}&version=2`; + return ( + + + +
+
+ +
+ {config[key].label} +
+ + Send notifications to {config[key].label} + +
+
+
+ ); + } + + return ( + { + await createNotifierMutation.mutateAsync({ + provider: key, + name: values.name, + data: { [key]: values.data }, + monitors: values.monitors, + }); + }} + disabled={!enabled} + > + + +
+
+ +
+ {config[key].label} +
+ + Send notifications to {config[key].label} + +
+
+
+ ); + })} + + +
+
+ + Your Notifier + +
+ + Missing a channel?{" "} + Contact us + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/notifications/layout.tsx b/apps/dashboard/src/app/(dashboard)/notifications/layout.tsx new file mode 100644 index 000000000..7dbaa0f50 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/notifications/layout.tsx @@ -0,0 +1,32 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { NavActions } from "./nav-actions"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const queryClient = getQueryClient(); + await queryClient.prefetchQuery(trpc.notification.list.queryOptions()); + return ( + + + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/notifications/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/notifications/nav-actions.tsx new file mode 100644 index 000000000..768c5c97f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/notifications/nav-actions.tsx @@ -0,0 +1,9 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; + +export function NavActions() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/notifications/page.tsx b/apps/dashboard/src/app/(dashboard)/notifications/page.tsx new file mode 100644 index 000000000..b7f03f9a8 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/notifications/page.tsx @@ -0,0 +1,12 @@ +import type { SearchParams } from "nuqs"; +import { Client } from "./client"; +import { searchParamsCache } from "./search-params"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise; +}) { + await searchParamsCache.parse(searchParams); + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/notifications/search-params.ts b/apps/dashboard/src/app/(dashboard)/notifications/search-params.ts new file mode 100644 index 000000000..71e74b820 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/notifications/search-params.ts @@ -0,0 +1,12 @@ +import { + createSearchParamsCache, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; + +export const searchParamsParsers = { + config: parseAsString, + channel: parseAsStringEnum(["pagerduty"]), +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/(dashboard)/onboarding/client.tsx b/apps/dashboard/src/app/(dashboard)/onboarding/client.tsx new file mode 100644 index 000000000..cd4b410ac --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/onboarding/client.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { + ActionCard, + ActionCardDescription, + ActionCardGroup, + ActionCardHeader, + ActionCardTitle, +} from "@/components/content/action-card"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { + FormCard, + FormCardContent, + FormCardDescription, + FormCardFooter, + FormCardHeader, + FormCardTitle, +} from "@/components/forms/form-card"; +import { CreateMonitorForm } from "@/components/forms/onboarding/create-monitor"; +import { CreatePageForm } from "@/components/forms/onboarding/create-page"; +import { LearnFromForm } from "@/components/forms/onboarding/learn-from"; +import { Button } from "@/components/ui/button"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowUpRight } from "lucide-react"; +import Link from "next/link"; +import { useQueryStates } from "nuqs"; +import { searchParamsParsers } from "./search-params"; + +const moreActions = [ + { + id: "notifier", + title: "Create a notifier", + description: "Get notified when your website or API is down.", + href: "/notifications", + }, + { + id: "workspace", + title: "Setup workspace", + description: "Add a name to your workspace and share it with your team.", + href: "/settings/general", + }, + { + id: "monitor", + title: "Update monitor", + description: "Change region, schedule, timeout and more.", + href: "/monitors", + }, + { + id: "cal", + title: "Schedule a call", + description: "Book a meeting with us to get you started with OpenStatus.", + href: "https://openstatus.dev/cal", + }, + { + id: "docs", + title: "Documentation", + description: "Read our documentation to get started with OpenStatus.", + href: "https://docs.openstatus.dev", + }, + { + id: "changelog", + title: "Changelog", + description: "See what's new in OpenStatus.", + href: "https://openstatus.dev/changelog", + }, +]; + +export function Client() { + const [{ step }, setSearchParams] = useQueryStates(searchParamsParsers); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const createMonitorMutation = useMutation( + trpc.monitor.create.mutationOptions({ + onSuccess: () => { + setSearchParams({ step: "2" }); + queryClient.invalidateQueries({ + queryKey: trpc.monitor.list.queryKey(), + }); + }, + }), + ); + const createPageMutation = useMutation( + trpc.page.create.mutationOptions({ + onSuccess: () => { + setSearchParams({ step: "next" }); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, + }), + ); + const createFeedbackMutation = useMutation( + trpc.feedback.submit.mutationOptions({}), + ); + + return ( + +
+ + Getting Started + + Welcome to OpenStatus. Let's get you set up. + + +
+ {step === "1" && ( +
+ + + Step 1 of{" "} + 2 + + + + + Create a monitor + + Get uptime, response time and more for your website or API. + + + + { + await createMonitorMutation.mutateAsync({ + url: values.url, + name: new URL(values.url).hostname, + // FIXME: undefined values should be allowed + headers: [], + assertions: undefined, + jobType: "http", + // FIXME: check which regions are available on free plan + regions: ["ams", "iad", "syd"], + active: true, + public: false, + workspaceId: workspace?.id, + }); + }} + /> + + + + + +
+ )} + {step === "2" && ( +
+ + + Step 2 of{" "} + 2 + + + + + Create a page + + Inform your users about the status of your website or API. + + + + { + if (!workspace?.id) return; + + await createPageMutation.mutateAsync({ + slug: values.slug, + title: values.slug.replace(/-/g, " "), + description: "", + monitors: createMonitorMutation.data?.id + ? [{ monitorId: createMonitorMutation.data.id, order: 0 }] + : [], + workspaceId: workspace.id, + }); + }} + /> + + + + + +
+ )} + {step === "next" && ( + <> +
+ + + We'd love to know how you heard about us. This will help us + improve our product and services. + + + { + await createFeedbackMutation.mutateAsync({ + message: `I learned about OpenStatus from ${values.from}${values.other ? `: ${values.other}` : ""}`, + }); + }} + /> +
+
+ + + What's next? + + + + {moreActions.map((action) => { + const isExternal = action.href.startsWith("http"); + const isMonitor = action.id === "monitor"; + const href = + isMonitor && createMonitorMutation.data?.id + ? `${action.href}/${createMonitorMutation.data.id}` + : action.href; + return ( + + + + + {action.title} + {isExternal && ( + + )} + + + {action.description} + + + + + ); + })} + +
+ + )} +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/onboarding/layout.tsx b/apps/dashboard/src/app/(dashboard)/onboarding/layout.tsx new file mode 100644 index 000000000..88e1c9e7b --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/onboarding/layout.tsx @@ -0,0 +1,25 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/onboarding/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/onboarding/nav-actions.tsx new file mode 100644 index 000000000..768c5c97f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/onboarding/nav-actions.tsx @@ -0,0 +1,9 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; + +export function NavActions() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/onboarding/page.tsx b/apps/dashboard/src/app/(dashboard)/onboarding/page.tsx new file mode 100644 index 000000000..6f63a2e2d --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/onboarding/page.tsx @@ -0,0 +1,14 @@ +import { Client } from "./client"; +import { searchParamsCache } from "./search-params"; + +import type { SearchParams } from "nuqs"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise; +}) { + await searchParamsCache.parse(searchParams); + + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/onboarding/search-params.ts b/apps/dashboard/src/app/(dashboard)/onboarding/search-params.ts new file mode 100644 index 000000000..1ee1a2e4d --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/onboarding/search-params.ts @@ -0,0 +1,9 @@ +import { createSearchParamsCache, parseAsStringLiteral } from "nuqs/server"; + +const STEPS = ["1", "2", "next"] as const; + +export const searchParamsParsers = { + step: parseAsStringLiteral(STEPS).withDefault("1"), +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/(dashboard)/overview/data-table-status-reports.tsx b/apps/dashboard/src/app/(dashboard)/overview/data-table-status-reports.tsx new file mode 100644 index 000000000..30d3ffc84 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/overview/data-table-status-reports.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { DataTable as UpdatesDataTable } from "@/components/data-table/status-report-updates/data-table"; +import { columns as statusReportsColumns } from "@/components/data-table/status-reports/columns"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import type { RouterOutputs } from "@openstatus/api"; + +type StatusReport = RouterOutputs["statusReport"]["list"][number]; + +export function DataTableStatusReports({ + statusReports, +}: { + statusReports: StatusReport[]; +}) { + return ( + + row.getCanExpand() ? row.toggleExpanded() : undefined + } + rowComponent={({ row }) => ( + + )} + /> + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/overview/layout.tsx b/apps/dashboard/src/app/(dashboard)/overview/layout.tsx new file mode 100644 index 000000000..3dfaae435 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/overview/layout.tsx @@ -0,0 +1,26 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/overview/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/overview/nav-actions.tsx new file mode 100644 index 000000000..768c5c97f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/overview/nav-actions.tsx @@ -0,0 +1,9 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; + +export function NavActions() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/overview/page.tsx b/apps/dashboard/src/app/(dashboard)/overview/page.tsx new file mode 100644 index 000000000..426d39bf9 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/overview/page.tsx @@ -0,0 +1,217 @@ +import { + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; + +import { Note, NoteButton } from "@/components/common/note"; +import { + EmptyStateContainer, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { Section } from "@/components/content/section"; +import { columns as incidentsColumns } from "@/components/data-table/incidents/columns"; +import { columns as maintenancesColumns } from "@/components/data-table/maintenances/columns"; +import { + MetricCard, + MetricCardGroup, + MetricCardHeader, + MetricCardTitle, + MetricCardValue, +} from "@/components/metric/metric-card"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { cn } from "@/lib/utils"; +import { formatDistanceToNowStrict } from "date-fns"; +import { List, Search } from "lucide-react"; +import { Terminal } from "lucide-react"; +import Link from "next/link"; +import { DataTableStatusReports } from "./data-table-status-reports"; + +// FIXME: the page is server side +// whenever I change the maintenances, the page is not updated +// we need to move the queryClient to the layout and prefetch the data there + +export default async function Page() { + const queryClient = getQueryClient(); + + const monitors = await queryClient.fetchQuery( + trpc.monitor.list.queryOptions(), + ); + const pages = await queryClient.fetchQuery(trpc.page.list.queryOptions()); + const incidents = await queryClient.fetchQuery( + trpc.incident.list.queryOptions({ + order: "desc", + startedAt: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago + }, + }), + ); + const statusReports = await queryClient.fetchQuery( + trpc.statusReport.list.queryOptions({ + order: "desc", + createdAt: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago + }, + }), + ); + const maintenances = await queryClient.fetchQuery( + trpc.maintenance.list.queryOptions({ + order: "desc", + createdAt: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days ago + }, + }), + ); + + const lastIncident = incidents.length > 0 ? incidents[0] : null; + const lastStatusReport = statusReports.length > 0 ? statusReports[0] : null; + const lastMaintenance = maintenances.length > 0 ? maintenances[0] : null; + + const incidentDistance = lastIncident + ? formatDistanceToNowStrict(lastIncident.startedAt, { + addSuffix: true, + }) + : "None"; + + const statusReportDistance = lastStatusReport?.createdAt + ? formatDistanceToNowStrict(lastStatusReport.createdAt, { + addSuffix: true, + }) + : "None"; + + const maintenanceDistance = lastMaintenance?.createdAt + ? formatDistanceToNowStrict(lastMaintenance.createdAt, { + addSuffix: true, + }) + : "None"; + + const metrics = [ + { + title: "Total Monitors", + value: monitors.length, + href: "/monitors", + variant: "default" as const, + icon: List, + }, + { + title: "Total Status Pages", + value: pages.length, + href: "/status-pages", + variant: "default" as const, + icon: List, + }, + { + title: "Recent Incident", + value: incidentDistance, + disabled: !lastIncident?.monitorId, + href: `/monitors/${lastIncident?.monitorId}/incidents`, + variant: "default" as const, + icon: Search, + }, + { + title: "Last Report", + value: statusReportDistance, + disabled: !lastStatusReport?.pageId, + href: `/status-pages/${lastStatusReport?.pageId}/status-reports`, + variant: "default" as const, + icon: Search, + }, + { + title: "Last Maintenance", + value: maintenanceDistance, + disabled: !lastMaintenance?.pageId, + href: `/status-pages/${lastMaintenance?.pageId}/maintenances`, + variant: "default" as const, + icon: Search, + }, + ]; + + return ( + + + + + Use Monitoring as Code to manage your monitors with our CLI. + + Learn more + + +
+ + Overview + + Welcome to your OpenStatus dashboard. + + + + {metrics.map((metric) => ( + + + + + {metric.title} + + + + {metric.value} + + + ))} + +
+
+ + Incidents + + Incidents over the last 7 days. + + + {incidents.length > 0 ? ( + + ) : ( + + No incidents found + + )} +
+
+ + Reports + + Reports over the last 7 days. + + + {statusReports.length > 0 ? ( + + ) : ( + + No reports found + + )} +
+
+ + Maintenance + + Maintenance over the last 7 days. + + + {maintenances.length > 0 ? ( + + ) : ( + + No maintenances found + + )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/page.tsx b/apps/dashboard/src/app/(dashboard)/page.tsx new file mode 100644 index 000000000..2d0fb1d66 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function Page() { + redirect("/overview"); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/(list)/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/(list)/layout.tsx new file mode 100644 index 000000000..8aa952cdb --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/(list)/layout.tsx @@ -0,0 +1,26 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/(list)/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/settings/(list)/nav-actions.tsx new file mode 100644 index 000000000..768c5c97f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/(list)/nav-actions.tsx @@ -0,0 +1,9 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; + +export function NavActions() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/(list)/page.tsx new file mode 100644 index 000000000..25a063214 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/(list)/page.tsx @@ -0,0 +1,61 @@ +import { Link } from "@/components/common/link"; +import { + ActionCard, + ActionCardDescription, + ActionCardGroup, + ActionCardHeader, + ActionCardTitle, +} from "@/components/content/action-card"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; + +const settings = [ + { + title: "General", + description: "Manage your workspace settings.", + href: "/settings/general", + }, + { + title: "Billing", + description: "Manage your billing information and payment methods.", + href: "/settings/billing", + }, + { + title: "Account", + description: "Manage your account information.", + href: "/settings/account", + }, +]; +export default function Page() { + return ( + +
+ + Settings + + All your settings in one place. + + + + {settings.map((setting) => ( + + + + {setting.title} + + {setting.description} + + + + + ))} + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/breadcrumb.tsx new file mode 100644 index 000000000..22f35c219 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/account/breadcrumb.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { Cog, CreditCard, User } from "lucide-react"; + +export function Breadcrumb() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/layout.tsx new file mode 100644 index 000000000..affb067cb --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/account/layout.tsx @@ -0,0 +1,26 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; + +import { Breadcrumb } from "./breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/nav-actions.tsx new file mode 100644 index 000000000..768c5c97f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/account/nav-actions.tsx @@ -0,0 +1,9 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; + +export function NavActions() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx new file mode 100644 index 000000000..511af2e7a --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/account/page.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { + Section, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { + FormCardDescription, + FormCardFooterInfo, + FormCardHeader, + FormCardTitle, + FormCardUpgrade, +} from "@/components/forms/form-card"; +import { + FormCard, + FormCardContent, + FormCardFooter, +} from "@/components/forms/form-card"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; + +export default function Page() { + const trpc = useTRPC(); + const { data: user } = useQuery(trpc.user.get.queryOptions()); + + if (!user) return null; + + return ( + +
+ + Account + + + + + Personal Information + + Manage your personal information. + + + +
+
+ + +
+
+ + +
+
+
+ + + Please contact us if you want to change your email or name. + + +
+ + + Appearance + + Choose your preferred theme. + + + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/breadcrumb.tsx new file mode 100644 index 000000000..22f35c219 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/breadcrumb.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { Cog, CreditCard, User } from "lucide-react"; + +export function Breadcrumb() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx new file mode 100644 index 000000000..41a80c84b --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/client.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { BillingProgress } from "@/components/content/billing-progress"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { DataTable } from "@/components/data-table/billing/data-table"; +import { + FormCard, + FormCardContent, + FormCardDescription, + FormCardFooter, + FormCardFooterInfo, + FormCardGroup, + FormCardHeader, + FormCardSeparator, + FormCardTitle, +} from "@/components/forms/form-card"; +import { Button } from "@/components/ui/button"; +import { useTRPC } from "@/lib/trpc/client"; +import type { Limits } from "@openstatus/db/src/schema/plan/schema"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useQueryStates } from "nuqs"; +import { useEffect, useMemo, useTransition } from "react"; +import { toast } from "sonner"; +import { searchParamsParsers } from "./search-params"; + +const BASE_URL = + process.env.NODE_ENV === "production" + ? "https://app.openstatus.dev" + : "http://localhost:3000"; + +function calculateTotalRequests(limits: Limits) { + const monitors = limits.monitors; + const maxRegions = limits["max-regions"]; + const periodicity = limits.periodicity; + + if (periodicity.includes("30s")) { + return monitors * maxRegions * 2 * 60 * 24 * 30; + } + + if (periodicity.includes("1m")) { + return monitors * maxRegions * 60 * 24 * 30; + } + + if (periodicity.includes("5m")) { + return monitors * maxRegions * 12 * 24 * 30; + } + + if (periodicity.includes("10m")) { + return monitors * maxRegions * 6 * 24 * 30; + } + + if (periodicity.includes("30m")) { + return monitors * maxRegions * 2 * 24 * 30; + } + + if (periodicity.includes("1h")) { + return monitors * maxRegions * 24 * 30; + } + + return 0; +} + +export function Client() { + const trpc = useTRPC(); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [{ success }, setSearchParams] = useQueryStates(searchParamsParsers); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const customerPortalMutation = useMutation( + trpc.stripeRouter.getUserCustomerPortal.mutationOptions({ + onSuccess: (url) => { + if (!url) return; + router.push(url); + }, + }), + ); + const { data: httpWorkspace30d } = useQuery({ + ...trpc.tinybird.workspace30d.queryOptions({ + type: "http", + }), + enabled: !!workspace, + }); + + const { data: tcpWorkspace30d } = useQuery({ + ...trpc.tinybird.workspace30d.queryOptions({ + type: "tcp", + }), + enabled: !!workspace, + }); + + useEffect(() => { + if (success) { + setTimeout(() => { + toast.success("Billing information updated", { + duration: 5_000, + onAutoClose: () => setSearchParams({ success: null }), + onDismiss: () => setSearchParams({ success: null }), + }); + }, 500); + } + }, [success, setSearchParams]); + + const totalRequests = useMemo(() => { + const httpRequests = httpWorkspace30d?.data?.reduce( + (acc, curr) => acc + curr.count, + 0, + ); + const tcpRequests = tcpWorkspace30d?.data?.reduce( + (acc, curr) => acc + curr.count, + 0, + ); + return (httpRequests ?? 0) + (tcpRequests ?? 0); + }, [httpWorkspace30d, tcpWorkspace30d]); + + if (!workspace) return null; + + return ( + +
+ + Billing + + Manage your billing information and payment methods. + + + + + + Limits + + Overview of your current limits. + + + +
+ + + +
+
+ + +
+ +
+
+ + + Access your{" "} + billing information,{" "} + invoices and{" "} + payment methods via Stripe. + + + +
+ + + Plans + + Choose a plan that fits your needs. + + + + + + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/layout.tsx new file mode 100644 index 000000000..affb067cb --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/layout.tsx @@ -0,0 +1,26 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; + +import { Breadcrumb } from "./breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/nav-actions.tsx new file mode 100644 index 000000000..768c5c97f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/nav-actions.tsx @@ -0,0 +1,9 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; + +export function NavActions() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/billing/page.tsx new file mode 100644 index 000000000..72bc20a78 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/page.tsx @@ -0,0 +1,13 @@ +import type { SearchParams } from "nuqs"; +import { Client } from "./client"; +import { searchParamsCache } from "./search-params"; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise; +}) { + await searchParamsCache.parse(searchParams); + + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts b/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts new file mode 100644 index 000000000..d96c8317b --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/billing/search-params.ts @@ -0,0 +1,7 @@ +import { createSearchParamsCache, parseAsBoolean } from "nuqs/server"; + +export const searchParamsParsers = { + success: parseAsBoolean, +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/(dashboard)/settings/general/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/settings/general/breadcrumb.tsx new file mode 100644 index 000000000..22f35c219 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/general/breadcrumb.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { Cog, CreditCard, User } from "lucide-react"; + +export function Breadcrumb() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx new file mode 100644 index 000000000..0c9a86dae --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx @@ -0,0 +1,38 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; + +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { Breadcrumb } from "./breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const queryClient = getQueryClient(); + await queryClient.prefetchQuery(trpc.member.list.queryOptions()); + await queryClient.prefetchQuery(trpc.invitation.list.queryOptions()); + await queryClient.prefetchQuery(trpc.apiKey.get.queryOptions()); + + return ( + +
+ + + + + + + + + +
{children}
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/general/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/settings/general/nav-actions.tsx new file mode 100644 index 000000000..768c5c97f --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/general/nav-actions.tsx @@ -0,0 +1,9 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; + +export function NavActions() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/general/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/general/page.tsx new file mode 100644 index 000000000..cc3514624 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/general/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { FormCardGroup } from "@/components/forms/form-card"; +import { FormApiKey } from "@/components/forms/settings/form-api-key"; +import { FormMembers } from "@/components/forms/settings/form-members"; +import { FormSlug } from "@/components/forms/settings/form-slug"; +import { FormWorkspace } from "@/components/forms/settings/form-workspace"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +const BASE_URL = "https://app.openstatus.dev/invite"; + +export default function Page() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const updateWorkspaceNameMutation = useMutation( + trpc.workspace.updateName.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.workspace.list.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.workspace.get.queryKey(), + }); + }, + }), + ); + const sendInvitationMutation = useMutation( + trpc.emailRouter.sendTeamInvitation.mutationOptions(), + ); + const createInvitationMutation = useMutation( + trpc.invitation.create.mutationOptions({ + onSuccess: (data) => { + sendInvitationMutation.mutate({ id: data.id, baseUrl: BASE_URL }); + queryClient.invalidateQueries({ + queryKey: trpc.invitation.list.queryKey(), + }); + }, + }), + ); + + if (!workspace) return null; + + return ( + +
+ + General + + Manage your workspace settings. + + + + { + await updateWorkspaceNameMutation.mutateAsync({ + name: values.name, + }); + }} + /> + + { + await createInvitationMutation.mutateAsync({ + email: values.email, + }); + }} + locked={ + (typeof workspace.limits.members === "number" && + workspace.limits.members === 1) || + workspace.limits.members !== "Unlimited" + } + /> + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/breadcrumb.tsx new file mode 100644 index 000000000..307c42e3c --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/breadcrumb.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; + +export function Breadcrumb() { + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx new file mode 100644 index 000000000..102c77965 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/client.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { columns } from "@/components/data-table/status-pages/columns"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; + +export function Client() { + const trpc = useTRPC(); + const { data: statusPages } = useQuery(trpc.page.list.queryOptions()); + + // TODO: add skeleton + if (!statusPages) return null; + + return ( + + + Status Pages + + Create and manage your status pages. + + + + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/layout.tsx new file mode 100644 index 000000000..affb067cb --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/layout.tsx @@ -0,0 +1,26 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; + +import { Breadcrumb } from "./breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/nav-actions.tsx new file mode 100644 index 000000000..1562d6285 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/nav-actions.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { UpgradeDialog } from "@/components/dialogs/upgrade"; +import { NavFeedback } from "@/components/nav/nav-feedback"; +import { Button } from "@/components/ui/button"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useState } from "react"; + +export function NavActions() { + const [openDialog, setOpenDialog] = useState(false); + const trpc = useTRPC(); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: statusPages } = useQuery(trpc.page.list.queryOptions()); + + if (!workspace || !statusPages) return null; + + const limitReached = statusPages.length >= workspace.limits["status-pages"]; + + return ( +
+ + {limitReached ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/page.tsx new file mode 100644 index 000000000..1fa143634 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/(list)/page.tsx @@ -0,0 +1,14 @@ +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { Client } from "./client"; + +export default async function Page() { + const queryClient = getQueryClient(); + + await queryClient.prefetchQuery(trpc.page.list.queryOptions()); + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx new file mode 100644 index 000000000..4da51ea52 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { Cog, Hammer, Megaphone, Users } from "lucide-react"; +import { useParams } from "next/navigation"; + +export function Breadcrumb() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const { data: statusPage } = useQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ); + + if (!statusPage) return null; + + return ( + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/edit/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/edit/page.tsx new file mode 100644 index 000000000..494659239 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/edit/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { FormStatusPageUpdate } from "@/components/forms/status-page/update"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +export default function Page() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const { data: statusPage } = useQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ); + + if (!statusPage) return null; + + return ( + +
+ + {statusPage.title} + Customize your status page. + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx new file mode 100644 index 000000000..d6b933c11 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/layout.tsx @@ -0,0 +1,43 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; + +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { Breadcrumb } from "./breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const queryClient = getQueryClient(); + + await queryClient.prefetchQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ); + await queryClient.prefetchQuery(trpc.monitor.list.queryOptions()); + + return ( + +
+ + + + + + + + + +
{children}
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx new file mode 100644 index 000000000..45572d9ee --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx @@ -0,0 +1,31 @@ +import { SidebarProvider } from "@/components/ui/sidebar"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { Sidebar } from "../sidebar"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const queryClient = await getQueryClient(); + + await queryClient.prefetchQuery( + trpc.maintenance.list.queryOptions({ + pageId: Number.parseInt(id), + }), + ); + + return ( + + +
{children}
+
+ +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx new file mode 100644 index 000000000..26aa831e1 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionHeaderRow, + SectionTitle, +} from "@/components/content/section"; +import { columns } from "@/components/data-table/maintenances/columns"; +import { FormSheetMaintenance } from "@/components/forms/maintenance/sheet"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import { useParams } from "next/navigation"; + +export default function Page() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const { data: statusPage } = useQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ); + const { data: maintenances, refetch } = useQuery( + trpc.maintenance.list.queryOptions({ + pageId: Number.parseInt(id), + }), + ); + const createStatusPageMutation = useMutation( + trpc.maintenance.new.mutationOptions({ + onSuccess: () => refetch(), + }), + ); + + if (!statusPage || !maintenances) return null; + + return ( + +
+ + + Maintenances + + See our maintenances and scheduled downtimes. + + +
+ { + await createStatusPageMutation.mutateAsync({ + pageId: Number.parseInt(id), + title: values.title, + message: values.message, + startDate: values.startDate, + endDate: values.endDate, + monitors: values.monitors, + }); + }} + > + + +
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx new file mode 100644 index 000000000..fd4e842a8 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { NavFeedback } from "@/components/nav/nav-feedback"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { getActions } from "@/data/status-pages.client"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Globe } from "lucide-react"; +import { useParams, usePathname, useRouter } from "next/navigation"; +import { toast } from "sonner"; + +export function NavActions() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const pathname = usePathname(); + + const { data: statusPage } = useQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ); + + const deleteStatusPageMutation = useMutation( + trpc.page.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + if (pathname.includes(`/status-pages/${id}`)) { + router.push("/status-pages"); + } + }, + }), + ); + + const actions = getActions({ + edit: () => router.push(`/status-pages/${id}/edit`), + "copy-id": () => { + navigator.clipboard.writeText("ID"); + toast.success("Status Page ID copied to clipboard"); + }, + }); + + if (!statusPage) return null; + + return ( +
+ + + + + + + View Page + + + { + await deleteStatusPageMutation.mutateAsync({ + id: Number.parseInt(id), + }); + }, + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/page.tsx new file mode 100644 index 000000000..4a6d5f79d --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + redirect(`/status-pages/${id}/status-reports`); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx new file mode 100644 index 000000000..b79cc7cca --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { Link } from "@/components/common/link"; +import { TableCellBoolean } from "@/components/data-table/table-cell-boolean"; +import { TableCellLink } from "@/components/data-table/table-cell-link"; +import { SidebarRight } from "@/components/nav/sidebar-right"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { ExternalLink } from "lucide-react"; +import { useParams } from "next/navigation"; + +// NOTE: +const BADGE_URL = + "https://openstatus.dev/status-page/hello-world/badge?size=sm&theme=light"; + +export function Sidebar() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const { data: statusPage } = useQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ); + + if (!statusPage) return null; + + return ( + + {statusPage.slug} + + ), + }, + { label: "Domain", value: statusPage.customDomain || "-" }, + { + label: "Favicon", + value: statusPage.icon ? ( +
+ favicon +
+ ) : ( + "-" + ), + }, + { + label: "Badge", + value: ( + + + + badge + + Learn more about the badge. + + + ), + }, + ], + }, + { + label: "Configuration", + items: [ + { + label: "Protected", + value: , + }, + { + label: "Show values", + value: , + }, + ], + }, + { + label: "Monitors", + items: statusPage.monitors.flatMap((monitor) => { + const arr = []; + arr.push({ + label: "Name", + value: ( + + ), + }); + arr.push({ + label: "URL", + value: monitor.url, + isNested: true, + }); + return arr; + }), + }, + ]} + footerButton={{ + onClick: () => + typeof window !== "undefined" && + window.open( + `https://${statusPage.customDomain || `${statusPage.slug}.openstatus.dev`}`, + "_blank", + ), + children: ( + <> + + Visit Status Page + + ), + }} + /> + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx new file mode 100644 index 000000000..9c927696c --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx @@ -0,0 +1,28 @@ +import { SidebarProvider } from "@/components/ui/sidebar"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import { Sidebar } from "../sidebar"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const queryClient = getQueryClient(); + const { id } = await params; + await queryClient.prefetchQuery( + trpc.statusReport.list.queryOptions({ pageId: Number.parseInt(id) }), + ); + + return ( + + +
{children}
+
+ +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx new file mode 100644 index 000000000..4633a4936 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionHeaderRow, + SectionTitle, +} from "@/components/content/section"; +import { DataTable as UpdatesDataTable } from "@/components/data-table/status-report-updates/data-table"; +import { columns } from "@/components/data-table/status-reports/columns"; +import { FormSheetStatusReport } from "@/components/forms/status-report/sheet"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import { useParams } from "next/navigation"; + +export default function Page() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data: page } = useQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ); + const { data: statusReports, refetch } = useQuery( + trpc.statusReport.list.queryOptions({ pageId: Number.parseInt(id) }), + ); + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); + const sendStatusReportUpdateMutation = useMutation( + trpc.emailRouter.sendStatusReport.mutationOptions(), + ); + const createStatusReportMutation = useMutation( + trpc.statusReport.create.mutationOptions({ + onSuccess: (update) => { + // TODO: move to server + sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); + // + refetch(); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, + }), + ); + + if (!statusReports || !monitors || !page) return null; + + const hasUnresolvedIssue = statusReports.some( + (report) => report.status !== "resolved", + ); + + return ( + +
+ + + {page.title} + + See our uptime history and status reports. + + +
+ + An unresolved report already exists. Consider adding a{" "} + status report update{" "} + instead. + + ) : undefined + } + monitors={monitors} + 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) { + await createStatusReportMutation.mutateAsync({ + title: values.title, + status: values.status, + pageId: Number.parseInt(id), + monitors: values.monitors, + date: values.date, + message: values.message, + }); + } + }} + > + + +
+
+ + row.getCanExpand() ? row.toggleExpanded() : undefined + } + rowComponent={({ row }) => ( + + )} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx new file mode 100644 index 000000000..012f9e6c7 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/layout.tsx @@ -0,0 +1,18 @@ +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string }>; +}) { + const queryClient = getQueryClient(); + const { id } = await params; + + await queryClient.prefetchQuery( + trpc.pageSubscriber.list.queryOptions({ pageId: Number.parseInt(id) }), + ); + + return {children}; +} 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 new file mode 100644 index 000000000..e97f9b793 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/subscribers/page.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Link } from "@/components/common/link"; +import { + BillingOverlay, + BillingOverlayButton, + BillingOverlayContainer, + BillingOverlayDescription, +} from "@/components/content/billing-overlay"; +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; + +import { Section } from "@/components/content/section"; +import { columns } from "@/components/data-table/subscribers/columns"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { Lock } from "lucide-react"; +import { useParams } from "next/navigation"; + +const EXAMPLES = [ + { + id: 1, + email: "max@openstatus.dev", + createdAt: new Date(), + updatedAt: null, + pageId: 1, + token: null, + acceptedAt: new Date(), + expiresAt: new Date(), + }, + { + id: 2, + email: "thibault@openstatus.dev", + createdAt: new Date(), + updatedAt: null, + pageId: 1, + token: null, + acceptedAt: new Date(), + expiresAt: new Date(), + }, +]; + +export default function Page() { + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const { data: page } = useQuery( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ); + const { data: subscribers } = useQuery( + trpc.pageSubscriber.list.queryOptions({ pageId: Number.parseInt(id) }), + ); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + + if (!workspace) return null; + + return ( + +
+ + {page?.title} + + Allow your users to subscribe to status page updates. + + +
+
+ {workspace.limits["status-subscribers"] === false ? ( + + + + + + Upgrade + + + Keep your users in the loop with status page updates.{" "} + + Learn more + + . + + + + ) : subscribers?.length ? ( + + ) : ( + + No subscribers + + No emails have been subscribed to this status page. + + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/create/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/create/breadcrumb.tsx new file mode 100644 index 000000000..ce5b96bbc --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/create/breadcrumb.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; + +export function Breadcrumb() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/create/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/create/client.tsx new file mode 100644 index 000000000..2d3b555f5 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/create/client.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { EmptyStateContainer } from "@/components/content/empty-state"; +import { + Section, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { FormGeneral } from "@/components/forms/status-page/form-general"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; + +export function Client() { + const [isPending, startTransition] = useTransition(); + const trpc = useTRPC(); + const router = useRouter(); + const queryClient = useQueryClient(); + const { refetch } = useQuery(trpc.page.list.queryOptions()); + const createStatusPageMutation = useMutation( + trpc.page.new.mutationOptions({ + onSuccess: (data) => { + refetch(); + // NOTE: invalidate workspace to update the usage + queryClient.invalidateQueries({ + queryKey: trpc.workspace.get.queryKey(), + }); + startTransition(() => { + router.push(`/status-pages/${data.id}/edit`); + }); + }, + }), + ); + + return ( + +
+ + Create Status Page + + { + await createStatusPageMutation.mutateAsync({ + title: values.title, + slug: values.slug, + icon: values.icon, + description: values.description, + }); + }} + /> +
+
+ + Create and start customizing + + Connect your monitors, set + up a custom domain,{" "} + password protect it and + more... + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/create/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/create/layout.tsx new file mode 100644 index 000000000..d154ddb79 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/create/layout.tsx @@ -0,0 +1,18 @@ +import { AppHeader, AppHeaderContent } from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; + +import { Breadcrumb } from "./breadcrumb"; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/create/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/create/page.tsx new file mode 100644 index 000000000..e3dc7ede6 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/create/page.tsx @@ -0,0 +1,5 @@ +import { Client } from "./client"; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/app/(public)/login/_components/actions.ts b/apps/dashboard/src/app/(public)/login/_components/actions.ts new file mode 100644 index 000000000..a90659b51 --- /dev/null +++ b/apps/dashboard/src/app/(public)/login/_components/actions.ts @@ -0,0 +1,11 @@ +"use server"; + +import { signIn } from "@/lib/auth"; + +export async function signInWithResendAction(formData: FormData) { + try { + await signIn("resend", formData); + } catch (e) { + console.error(e); + } +} diff --git a/apps/dashboard/src/app/(public)/login/_components/magic-link-form.tsx b/apps/dashboard/src/app/(public)/login/_components/magic-link-form.tsx new file mode 100644 index 000000000..017055878 --- /dev/null +++ b/apps/dashboard/src/app/(public)/login/_components/magic-link-form.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useFormStatus } from "react-dom"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; +import { signInWithResendAction } from "./actions"; + +/** + * @deprecated - only to be used in development mode + */ +export default function MagicLinkForm() { + const { pending } = useFormStatus(); + + return ( +
{ + try { + await signInWithResendAction(formData); + toast.success("Check your terminal for the magic link."); + } catch (e) { + console.error(e); + toast.error("Error sending magic link."); + } + }} + className="grid gap-2" + > +
+ + +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(public)/login/layout.tsx b/apps/dashboard/src/app/(public)/login/layout.tsx new file mode 100644 index 000000000..444f0ce30 --- /dev/null +++ b/apps/dashboard/src/app/(public)/login/layout.tsx @@ -0,0 +1,56 @@ +import Image from "next/image"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +import { auth } from "@/lib/auth"; + +export default async function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth(); + if (session) redirect("/"); + + return ( +
+ +
+ {children} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(public)/login/page.tsx b/apps/dashboard/src/app/(public)/login/page.tsx new file mode 100644 index 000000000..da6030375 --- /dev/null +++ b/apps/dashboard/src/app/(public)/login/page.tsx @@ -0,0 +1,75 @@ +import Link from "next/link"; + +import { GitHubIcon } from "@/components/icons/github"; +import { GoogleIcon } from "@/components/icons/google"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { signIn } from "@/lib/auth"; +import type { SearchParams } from "nuqs/server"; +import MagicLinkForm from "./_components/magic-link-form"; +import { searchParamsCache } from "./search-params"; + +export default async function Page(props: { + searchParams: Promise; +}) { + const searchParams = await props.searchParams; + const { redirectTo } = searchParamsCache.parse(searchParams); + + return ( +
+
+

Sign In

+

+ Get started now. No credit card required. +

+
+
+ {process.env.NODE_ENV === "development" ? ( +
+ + +
+ ) : null} +
{ + "use server"; + await signIn("github", { redirectTo }); + }} + className="w-full" + > + +
+
{ + "use server"; + await signIn("google", { redirectTo }); + }} + className="w-full" + > + +
+
+

+ By clicking continue, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . +

+
+ ); +} diff --git a/apps/dashboard/src/app/(public)/login/search-params.ts b/apps/dashboard/src/app/(public)/login/search-params.ts new file mode 100644 index 000000000..3447a1e24 --- /dev/null +++ b/apps/dashboard/src/app/(public)/login/search-params.ts @@ -0,0 +1,7 @@ +import { createSearchParamsCache, parseAsString } from "nuqs/server"; + +export const searchParamsParsers = { + redirectTo: parseAsString.withDefault("/"), +}; + +export const searchParamsCache = createSearchParamsCache(searchParamsParsers); diff --git a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 000000000..c55a45ed7 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth"; + +export const { GET, POST } = handlers; diff --git a/apps/dashboard/src/app/api/trpc/edge/[trpc]/route.ts b/apps/dashboard/src/app/api/trpc/edge/[trpc]/route.ts new file mode 100644 index 000000000..2c1fc5285 --- /dev/null +++ b/apps/dashboard/src/app/api/trpc/edge/[trpc]/route.ts @@ -0,0 +1,21 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import type { NextRequest } from "next/server"; + +import { createTRPCContext } from "@openstatus/api"; +import { edgeRouter } from "@openstatus/api/src/edge"; + +export const runtime = "edge"; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: "/api/trpc/edge", + router: edgeRouter, + req: req, + createContext: () => createTRPCContext({ req }), + onError: ({ error }) => { + console.log("Error in tRPC handler (edge)"); + console.error(error); + }, + }); + +export { handler as GET, handler as POST }; diff --git a/apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts b/apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts new file mode 100644 index 000000000..b36568088 --- /dev/null +++ b/apps/dashboard/src/app/api/trpc/lambda/[trpc]/route.ts @@ -0,0 +1,22 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import type { NextRequest } from "next/server"; + +import { createTRPCContext } from "@openstatus/api"; +import { lambdaRouter } from "@openstatus/api/src/lambda"; + +// Stripe is incompatible with Edge runtimes due to using Node.js events +// export const runtime = "edge"; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: "/api/trpc/lambda", + router: lambdaRouter, + req: req, + createContext: () => createTRPCContext({ req }), + onError: ({ error }) => { + console.log("Error in tRPC handler (lambda)"); + console.error(error); + }, + }); + +export { handler as GET, handler as POST }; diff --git a/apps/dashboard/src/app/apple.ico b/apps/dashboard/src/app/apple.ico new file mode 100644 index 0000000000000000000000000000000000000000..5118170333ebbe82c9fc99f91768d0746b753fe0 GIT binary patch literal 4158 zcmZQzU}RuqkXHcF3Je+o3=Con3=A3!3=HxN3=9qo5OD?u28ITH1_ohn2nO*%`2Q#v z4FNnM@c#Y#|5vVD`M+n+p8uOSZwAwsE?xQ$633&BY^i_${{269?%e;Xs;d9y=H~y! z#Kit{aB%!*Wn~4^qN1Yz&CJZe@@LPU{r~sxU$X5&*7)+}OR$+zQd0jJ7#Ij*NlD57 z6%`f#pFMkqYz)cl8#iwJ_weu_XdgB=IXgT5zk2m5$##M?+`W7Ezpbq;S@y!*Vrgml z|K`n`AmfO}pFe;89}y8jk-aecLqbCSgUln=@}*0c{^#T4`_InKj)K|P*#2X~pftzC z#DvwYAocU-&nL=iP@2AQ;llqdTeiS4C@+EVrcImv!|=w98~?-bh7BA32L=XWwHxGr zP}skF_l}_b$BrHQ@9*#b-_OqvOoP}U3^E&pgMxzogK%(g@PANv!mzr!I!?PeIXVCD z*s+74-F0Ers z><0M>pWUD^p;efJ%mtZ`%ikbzP?{h&J>k>W(9i%f9xvXxbLW3hp1@^}sj2Dz$jC@I z2Bjg8pTfez{)dK!g7Y0H|AF!$C{Kbgh<*I{alH0|BtiACjg1Y?^p=v6@_+T})&JM5 zS@VDG+O_}Jty}kh{rdI)LFEe!gUTFa_Qi`A|AWdbkoko0qD70qWhpklgUU{3X6FAa zEG%#gDpQd$s2qi1P}#g{)ha?(!sWkw`SL$1Dhj9HvAGGC7|0xud2qvtVBEWR@4ua$ z9Yywo^n=VH!a58kpt{%7)AK(gBO^&}0IB!#@*=tJMt1 z^3$tVuh5Mmiw{bp=g*%9x7$E%MNr!iy{%|rVe!AFrsh8>X_{2Wfb!tw%a{M}-n|=K XmxE|pwvkD7->CY*8v>v{_&{O+L|Yx= literal 0 HcmV?d00001 diff --git a/apps/dashboard/src/app/favicon.ico b/apps/dashboard/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5118170333ebbe82c9fc99f91768d0746b753fe0 GIT binary patch literal 4158 zcmZQzU}RuqkXHcF3Je+o3=Con3=A3!3=HxN3=9qo5OD?u28ITH1_ohn2nO*%`2Q#v z4FNnM@c#Y#|5vVD`M+n+p8uOSZwAwsE?xQ$633&BY^i_${{269?%e;Xs;d9y=H~y! z#Kit{aB%!*Wn~4^qN1Yz&CJZe@@LPU{r~sxU$X5&*7)+}OR$+zQd0jJ7#Ij*NlD57 z6%`f#pFMkqYz)cl8#iwJ_weu_XdgB=IXgT5zk2m5$##M?+`W7Ezpbq;S@y!*Vrgml z|K`n`AmfO}pFe;89}y8jk-aecLqbCSgUln=@}*0c{^#T4`_InKj)K|P*#2X~pftzC z#DvwYAocU-&nL=iP@2AQ;llqdTeiS4C@+EVrcImv!|=w98~?-bh7BA32L=XWwHxGr zP}skF_l}_b$BrHQ@9*#b-_OqvOoP}U3^E&pgMxzogK%(g@PANv!mzr!I!?PeIXVCD z*s+74-F0Ers z><0M>pWUD^p;efJ%mtZ`%ikbzP?{h&J>k>W(9i%f9xvXxbLW3hp1@^}sj2Dz$jC@I z2Bjg8pTfez{)dK!g7Y0H|AF!$C{Kbgh<*I{alH0|BtiACjg1Y?^p=v6@_+T})&JM5 zS@VDG+O_}Jty}kh{rdI)LFEe!gUTFa_Qi`A|AWdbkoko0qD70qWhpklgUU{3X6FAa zEG%#gDpQd$s2qi1P}#g{)ha?(!sWkw`SL$1Dhj9HvAGGC7|0xud2qvtVBEWR@4ua$ z9Yywo^n=VH!a58kpt{%7)AK(gBO^&}0IB!#@*=tJMt1 z^3$tVuh5Mmiw{bp=g*%9x7$E%MNr!iy{%|rVe!AFrsh8>X_{2Wfb!tw%a{M}-n|=K XmxE|pwvkD7->CY*8v>v{_&{O+L|Yx= literal 0 HcmV?d00001 diff --git a/apps/dashboard/src/app/global-error.tsx b/apps/dashboard/src/app/global-error.tsx new file mode 100644 index 000000000..f9babe1cf --- /dev/null +++ b/apps/dashboard/src/app/global-error.tsx @@ -0,0 +1,25 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import NextError from "next/error"; +import { useEffect } from "react"; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + Sentry.captureException(error); + }, [error]); + + return ( + + + {/* This is the default Next.js error component but it doesn't allow omitting the statusCode property yet. */} + {/* biome-ignore lint/suspicious/noExplicitAny: */} + + + + ); +} diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css new file mode 100644 index 000000000..d9d01abe2 --- /dev/null +++ b/apps/dashboard/src/app/globals.css @@ -0,0 +1,143 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +/* safelist */ +@source inline("has-data-[slot=slider-range]:bg-red-500"); + +@custom-variant dark (&:is(.dark *)); + +@theme { + --breakpoint-xs: 30rem; +} + +@theme inline { + --font-cal: var(--font-cal-sans); + + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --color-success: var(--success); + --color-warning: var(--warning); + --color-info: var(--info); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + --success: oklch(0.72 0.19 150); + --warning: oklch(0.77 0.16 70); + --info: oklch(0.62 0.19 260); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + + --success: oklch(0.72 0.19 150); + --warning: oklch(0.77 0.16 70); + --info: oklch(0.62 0.19 260); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx new file mode 100644 index 000000000..2f4010127 --- /dev/null +++ b/apps/dashboard/src/app/layout.tsx @@ -0,0 +1,66 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { DevelopmentIndicator } from "@/components/development-indicator"; +import { TailwindIndicator } from "@/components/tailwind-indicator"; +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "@/components/ui/sonner"; +import LocalFont from "next/font/local"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { ogMetadata, twitterMetadata } from "./metadata"; +import { defaultMetadata } from "./metadata"; + +const cal = LocalFont({ + src: "../../public/fonts/CalSans-SemiBold.ttf", + variable: "--font-cal-sans", +}); + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + ...defaultMetadata, + twitter: { + ...twitterMetadata, + }, + openGraph: { + ...ogMetadata, + }, +}; + +// export const dynamic = "error"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + {children} + + + + + + + + ); +} diff --git a/apps/dashboard/src/app/metadata.ts b/apps/dashboard/src/app/metadata.ts new file mode 100644 index 000000000..fd9971e17 --- /dev/null +++ b/apps/dashboard/src/app/metadata.ts @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; + +export const TITLE = "OpenStatus"; +export const DESCRIPTION = + "OpenStatus is an open-source platform to monitor your services and keep your users informed."; + +const OG_TITLE = "OpenStatus"; +const OG_DESCRIPTION = "Monitor your services and keep your users informed."; +const FOOTER = "app.openstatus.dev"; +const IMAGE = "assets/og/dashboard-v2.png"; + +export const defaultMetadata: Metadata = { + title: { + template: `%s | ${TITLE}`, + default: TITLE, + }, + description: DESCRIPTION, + metadataBase: new URL("https://www.openstatus.dev"), +}; + +export const twitterMetadata: Metadata["twitter"] = { + title: TITLE, + description: DESCRIPTION, + card: "summary_large_image", + images: [ + `/api/og?title=${OG_TITLE}&description=${OG_DESCRIPTION}&footer=${FOOTER}&image=${IMAGE}`, + ], +}; + +export const ogMetadata: Metadata["openGraph"] = { + title: TITLE, + description: DESCRIPTION, + type: "website", + images: [ + `/api/og?title=${OG_TITLE}&description=${OG_DESCRIPTION}&footer=${FOOTER}&image=${IMAGE}`, + ], +}; diff --git a/apps/dashboard/src/app/react-table.d.ts b/apps/dashboard/src/app/react-table.d.ts new file mode 100644 index 000000000..3e6ce4ab1 --- /dev/null +++ b/apps/dashboard/src/app/react-table.d.ts @@ -0,0 +1,8 @@ +import "@tanstack/react-table"; + +declare module "@tanstack/react-table" { + interface ColumnMeta { + headerClassName?: string; + cellClassName?: string; + } +} diff --git a/apps/dashboard/src/app/status-page/layout.tsx b/apps/dashboard/src/app/status-page/layout.tsx new file mode 100644 index 000000000..f3b5ff718 --- /dev/null +++ b/apps/dashboard/src/app/status-page/layout.tsx @@ -0,0 +1,48 @@ +/** + * TODO: + * - add different header + * - add different chart/tracker + * - add subscription popover (choose which one you'd like to allow) + * - use the '@/components/status-page` for the components + */ + +import { Link } from "@/components/common/link"; +import { Button } from "@/components/ui/button"; +import NextLink from "next/link"; + +const nav = [ + { label: "Status", href: "#" }, + { label: "Events", href: "#" }, + { label: "Monitors", href: "#" }, +]; + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+
+ +
+
+ {children} +
+
+
+

+ Powered by OpenStatus +

+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/status-page/page.tsx b/apps/dashboard/src/app/status-page/page.tsx new file mode 100644 index 000000000..c4d2facd5 --- /dev/null +++ b/apps/dashboard/src/app/status-page/page.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { + FloatingButton, + StatusPageProvider, + useStatusPage, +} from "@/components/status-page/floating-button"; +import { + Status, + StatusBanner, + StatusContent, + StatusDescription, + StatusHeader, + StatusTitle, +} from "@/components/status-page/status"; +import { StatusMonitor } from "@/components/status-page/status-monitor"; + +function StatusPageContent() { + const { variant, cardType } = useStatusPage(); + + return ( +
+ + + Craft + + Stay informed about the stability + + + + + + + + +
+ ); +} + +export default function Page() { + return ( + + + + ); +} diff --git a/apps/dashboard/src/components/chart/chart-area-latency.tsx b/apps/dashboard/src/components/chart/chart-area-latency.tsx new file mode 100644 index 000000000..ebe2a6b1a --- /dev/null +++ b/apps/dashboard/src/components/chart/chart-area-latency.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { + Area, + AreaChart, + CartesianGrid, + Label, + ReferenceLine, + XAxis, + YAxis, +} from "recharts"; + +import { + type ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { type PERCENTILES, mapLatency } from "@/data/metrics.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { flyRegions } from "@openstatus/db/src/schema/constants"; +import { useQuery } from "@tanstack/react-query"; +import { ChartTooltipNumber } from "./chart-tooltip-number"; + +const chartConfig = { + latency: { + label: "Latency", + color: "var(--success)", + }, +} satisfies ChartConfig; + +// TODO: create new pipes for timing phase metrics + +export function ChartAreaLatency({ + monitorId, + degradedAfter, + period, + type, + percentile, + regions, +}: { + monitorId: string; + degradedAfter: number | null; + percentile: (typeof PERCENTILES)[number]; + period: "1d" | "7d" | "14d"; + type: "http" | "tcp"; + regions: (typeof flyRegions)[number][]; +}) { + const trpc = useTRPC(); + + const { data: latency } = useQuery( + trpc.tinybird.metricsLatency.queryOptions({ + monitorId, + period, + type, + regions, + }), + ); + + const refinedLatency = latency ? mapLatency(latency, percentile) : []; + + return ( + + + + value.slice(0, 3)} + /> + ( + + )} + /> + } + /> + + {degradedAfter ? ( + + + ) : null} + `${value}ms`} + /> + } /> + + + ); +} diff --git a/apps/dashboard/src/components/chart/chart-area-timing-phases.tsx b/apps/dashboard/src/components/chart/chart-area-timing-phases.tsx new file mode 100644 index 000000000..5339f04c2 --- /dev/null +++ b/apps/dashboard/src/components/chart/chart-area-timing-phases.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { + Area, + AreaChart, + CartesianGrid, + Label, + ReferenceLine, + XAxis, + YAxis, +} from "recharts"; + +import { + type ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { + type INTERVALS, + type PERCENTILES, + mapTimingPhases, +} from "@/data/metrics.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { flyRegions } from "@openstatus/db/src/schema/constants"; +import { useQuery } from "@tanstack/react-query"; +import { + ChartTooltipNumber, + ChartTooltipNumberRaw, +} from "./chart-tooltip-number"; + +const chartConfig = { + dns: { + label: "DNS", + color: "var(--chart-1)", + }, + connect: { + label: "Connect", + color: "var(--chart-2)", + }, + tls: { + label: "TLS", + color: "var(--chart-3)", + }, + ttfb: { + label: "TTFB", + color: "var(--chart-4)", + }, + transfer: { + label: "Transfer", + color: "var(--chart-5)", + }, +} satisfies ChartConfig; + +// TODO: create new pipes for timing phase metrics + +export function ChartAreaTimingPhases({ + monitorId, + degradedAfter, + period, + percentile, + interval, + type, + regions, +}: { + monitorId: string; + degradedAfter: number | null; + period: "1d" | "7d" | "14d"; + percentile: (typeof PERCENTILES)[number]; + interval: (typeof INTERVALS)[number]; + regions: (typeof flyRegions)[number][]; + type: "http"; +}) { + const trpc = useTRPC(); + + const { data: timingPhases } = useQuery( + trpc.tinybird.metricsTimingPhases.queryOptions({ + monitorId, + period, + type, + interval, + regions, + }), + ); + + const refinedTimingPhases = timingPhases + ? mapTimingPhases(timingPhases, percentile) + : []; + + console.log(refinedTimingPhases); + return ( + + + + value.slice(0, 3)} + /> + { + if (index !== 4) { + return ( + + ); + } + + const total = + item.payload?.dns + + item.payload?.connect + + item.payload?.tls + + item.payload?.ttfb + + item.payload?.transfer; + + return ( + <> + + + + ); + }} + /> + } + /> + + + + + + {degradedAfter ? ( + + + ) : null} + `${value}ms`} + /> + } /> + + + ); +} diff --git a/apps/dashboard/src/components/chart/chart-bar-uptime.tsx b/apps/dashboard/src/components/chart/chart-bar-uptime.tsx new file mode 100644 index 000000000..ca7431b1f --- /dev/null +++ b/apps/dashboard/src/components/chart/chart-bar-uptime.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; + +import { + type ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { type PERIODS, mapUptime } from "@/data/metrics.client"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { useTRPC } from "@/lib/trpc/client"; +import type { Region } from "@openstatus/db/src/schema/constants"; +import { useQuery } from "@tanstack/react-query"; +import { endOfDay, startOfDay, subDays } from "date-fns"; + +const chartConfig = { + ok: { + label: "Success", + color: "var(--color-success)", + }, + degraded: { + label: "Degraded", + color: "var(--color-warning)", + }, + error: { + label: "Error", + color: "var(--color-destructive)", + }, +} satisfies ChartConfig; + +const periodToInterval = { + "1d": 60, + "7d": 240, + "14d": 480, +} satisfies Record<(typeof PERIODS)[number], number>; + +const periodToFromDate = { + "1d": startOfDay(subDays(new Date(), 1)), + "7d": startOfDay(subDays(new Date(), 7)), + "14d": startOfDay(subDays(new Date(), 14)), +} satisfies Record<(typeof PERIODS)[number], Date>; + +export function ChartBarUptime({ + monitorId, + period, + type, + regions, +}: { + monitorId: string; + period: (typeof PERIODS)[number]; + type: "http" | "tcp"; + regions: Region[]; +}) { + const isMobile = useIsMobile(); + const trpc = useTRPC(); + const fromDate = periodToFromDate[period]; + const toDate = endOfDay(new Date()); + const interval = periodToInterval[period]; + + const { data: uptime } = useQuery( + trpc.tinybird.uptime.queryOptions({ + monitorId, + fromDate: fromDate.toISOString(), + toDate: toDate.toISOString(), + regions, + interval, + type, + }), + ); + + const refinedUptime = uptime ? mapUptime(uptime) : []; + + return ( + + + + } + /> + + + + + + } /> + + + ); +} diff --git a/apps/dashboard/src/components/chart/chart-line-region.tsx b/apps/dashboard/src/components/chart/chart-line-region.tsx new file mode 100644 index 000000000..8f279fdf4 --- /dev/null +++ b/apps/dashboard/src/components/chart/chart-line-region.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; + +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { cn } from "@/lib/utils"; +import { ChartTooltipNumber } from "./chart-tooltip-number"; + +const chartConfig = { + latency: { + label: "Latency", + color: "var(--success)", + }, +} satisfies ChartConfig; + +export type TrendPoint = { + timestamp: number; // unix millis + latency: number; // milliseconds +}; + +export function ChartLineRegion({ + className, + data, +}: { + className?: string; + data: TrendPoint[]; +}) { + const trendData = data ?? []; + + const chartData = trendData.map((d) => ({ + timestamp: new Date(d.timestamp).toLocaleString("default", { + hour: "numeric", + minute: "numeric", + day: "numeric", + month: "short", + }), + latency: d.latency, + })); + + return ( + + + + + ( + + )} + /> + } + /> + + `${value}ms`} + /> + + + ); +} diff --git a/apps/dashboard/src/components/chart/chart-tooltip-number.tsx b/apps/dashboard/src/components/chart/chart-tooltip-number.tsx new file mode 100644 index 000000000..ce152b7d9 --- /dev/null +++ b/apps/dashboard/src/components/chart/chart-tooltip-number.tsx @@ -0,0 +1,59 @@ +import type { ChartConfig } from "@/components/ui/chart"; +import { cn } from "@/lib/utils"; +import type { + NameType, + ValueType, +} from "recharts/types/component/DefaultTooltipContent"; + +interface ChartTooltipNumberProps { + chartConfig: ChartConfig; + value: ValueType; + name: NameType; +} + +export function ChartTooltipNumber({ + value, + name, + chartConfig, +}: ChartTooltipNumberProps) { + return ( + + ); +} + +export function ChartTooltipNumberRaw({ + value, + label, + style, + className, +}: { + value: ValueType; + label: React.ReactNode; + style?: React.CSSProperties; + className?: string; +}) { + return ( + <> +
+ {label} +
+ {value} + ms +
+ + ); +} diff --git a/apps/dashboard/src/components/common/code.tsx b/apps/dashboard/src/components/common/code.tsx new file mode 100644 index 000000000..5ef399475 --- /dev/null +++ b/apps/dashboard/src/components/common/code.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { cn } from "@/lib/utils"; +import { Check, Copy } from "lucide-react"; + +export function Code({ + children, + className, + ...props +}: React.ComponentProps<"pre">) { + const { copy, isCopied } = useCopyToClipboard(); + + return ( +
+
+        {children}
+      
+ +
+ ); +} diff --git a/apps/dashboard/src/components/common/hover-card-timestamp.tsx b/apps/dashboard/src/components/common/hover-card-timestamp.tsx new file mode 100644 index 000000000..671d975d4 --- /dev/null +++ b/apps/dashboard/src/components/common/hover-card-timestamp.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { UTCDate } from "@date-fns/utc"; +import { HoverCardPortal } from "@radix-ui/react-hover-card"; +import { format, formatDistanceToNowStrict } from "date-fns"; +import { Copy } from "lucide-react"; +import { Check } from "lucide-react"; +import type { ComponentPropsWithoutRef } from "react"; + +// TODO: move to TableCellDate? + +type HoverCardContentProps = ComponentPropsWithoutRef; + +interface HoverCardTimestampProps { + date: Date; + side?: HoverCardContentProps["side"]; + sideOffset?: HoverCardContentProps["sideOffset"]; + align?: HoverCardContentProps["align"]; + alignOffset?: HoverCardContentProps["alignOffset"]; + children?: React.ReactNode; +} + +export function HoverCardTimestamp({ + date, + side = "right", + align = "start", + alignOffset = -4, + sideOffset, + children, +}: HoverCardTimestampProps) { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + return ( + + {children} + + +
+ + + + +
+
+
+
+ ); +} + +function Row({ value, label }: { value: string; label: string }) { + const { copy, isCopied } = useCopyToClipboard(); + + return ( +
{ + e.stopPropagation(); + copy(value, {}); + }} + > +
{label}
+
+ + {!isCopied ? ( + + ) : ( + + )} + + {value} +
+
+ ); +} diff --git a/apps/dashboard/src/components/common/input-with-addons.tsx b/apps/dashboard/src/components/common/input-with-addons.tsx new file mode 100644 index 000000000..3bf41c66f --- /dev/null +++ b/apps/dashboard/src/components/common/input-with-addons.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { Input } from "../ui/input"; + +export interface InputWithAddonsProps + extends React.InputHTMLAttributes { + leading?: React.ReactNode; + trailing?: React.ReactNode; +} + +// "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", +// "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", +// "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + +const InputWithAddons = React.forwardRef< + HTMLInputElement, + InputWithAddonsProps +>(({ leading, trailing, className, ...props }, ref) => { + return ( +
+ {leading ? ( + + {leading} + + ) : null} + + {trailing ? ( + + {trailing} + + ) : null} +
+ ); +}); + +InputWithAddons.displayName = "InputWithAddons"; + +export { InputWithAddons }; diff --git a/apps/dashboard/src/components/common/kbd.tsx b/apps/dashboard/src/components/common/kbd.tsx new file mode 100644 index 000000000..92724c885 --- /dev/null +++ b/apps/dashboard/src/components/common/kbd.tsx @@ -0,0 +1,32 @@ +import { cn } from "@/lib/utils"; +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +const kbdVariants = cva( + "-me-1 ms-2 inline-flex h-5 max-h-full items-center rounded border px-1 font-[inherit] font-medium text-[0.625rem]", + { + variants: { + variant: { + default: "border-input bg-background text-muted-foreground/70", + secondary: "bg-secondary text-secondary-foreground", + ghost: "border-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export function Kbd({ + children, + className, + variant, + ...props +}: React.ComponentProps<"kbd"> & VariantProps) { + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/components/common/link.tsx b/apps/dashboard/src/components/common/link.tsx new file mode 100644 index 000000000..6749263d8 --- /dev/null +++ b/apps/dashboard/src/components/common/link.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/lib/utils"; +import NextLink from "next/link"; + +// TODO: we could add cva variants for the link + +export function Link({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/components/common/note.tsx b/apps/dashboard/src/components/common/note.tsx new file mode 100644 index 000000000..5d9d2c761 --- /dev/null +++ b/apps/dashboard/src/components/common/note.tsx @@ -0,0 +1,60 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { type VariantProps, cva } from "class-variance-authority"; + +const noteVariants = cva( + "flex items-center gap-3 rounded-md border px-3 py-2 [&>svg]:size-4 [&>svg]:text-current [&>svg]:shrink-0", + { + variants: { + variant: { + default: "border-border", + ghost: "border-none bg-transparent", + }, + color: { + default: "text-foreground bg-sidebar", + warning: "text-warning border-warning/50 bg-warning/5", + error: "text-destructive border-destructive/50 bg-destructive/5", + success: "text-success border-success/50 bg-success/5", + info: "text-info border-info/50 bg-info/5", + }, + }, + defaultVariants: { + variant: "default", + color: "default", + }, + }, +); + +export function Note({ + children, + className, + variant = "default", + color = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ {children} +
+ ); +} + +export function NoteButton({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/components/common/wheel-picker.tsx b/apps/dashboard/src/components/common/wheel-picker.tsx new file mode 100644 index 000000000..a61eb0282 --- /dev/null +++ b/apps/dashboard/src/components/common/wheel-picker.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import * as React from "react"; + +// --------------------------------------------------------------------------------------------------------------------- +// Context ------------------------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------------------------------- + +interface WheelPickerContextValue { + items: string[]; + currentIndex: number; + onIndexChange: (index: number) => void; + theta: number; // angle between two items + radius: number; // translateZ distance + count: number; // items including placeholders +} + +const WheelPickerContext = React.createContext( + null, +); + +const useWheelPickerContext = () => { + const ctx = React.useContext(WheelPickerContext); + if (!ctx) { + throw new Error( + "[WheelPicker] sub component must be rendered within root", + ); + } + return ctx; +}; + +// --------------------------------------------------------------------------------------------------------------------- +// WheelPicker (Root / Provider) --------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------------------------------- + +export interface WheelPickerProps extends React.HTMLAttributes { + items: string[]; + /** 0-based index of the currently selected item. Defaults to 0. */ + currentIndex: number; + /** Callback that is invoked with the currently selected item (string) whenever the selection changes */ + onIndexChange: (index: number) => void; + /** Radius (in `px`) of the carousel – tweak to fit line height of text (Default: 28) */ + radius?: number; +} + +const WheelPicker = React.forwardRef( + ( + { + items, + currentIndex, + onIndexChange, + radius: radiusProp = 28, + className, + children, + ...props + }, + ref, + ) => { + // internal render count includes two placeholders at start & end + const count = items.length + 2; + const theta = (2 * Math.PI) / count; + + const contextValue = React.useMemo( + () => ({ + items, + currentIndex, + onIndexChange, + theta, + radius: radiusProp, + count, + }), + [items, currentIndex, onIndexChange, theta, radiusProp, count], + ); + + return ( + +
+ {children} +
+
+ ); + }, +); +WheelPicker.displayName = "WheelPicker"; + +// --------------------------------------------------------------------------------------------------------------------- +// WheelPickerSelect --------------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------------------------------- + +export type WheelPickerSelectProps = React.HTMLAttributes; + +const WheelPickerSelect = React.forwardRef< + HTMLDivElement, + WheelPickerSelectProps +>(({ className, children, ...props }, ref) => { + const { items, currentIndex, onIndexChange } = useWheelPickerContext(); + + const moveBy = React.useCallback( + (delta: number) => { + const itemsLength = items.length; + let newIndex = currentIndex + delta; + + // Handle wrapping + if (newIndex < 0) { + newIndex = itemsLength - 1; + } else if (newIndex >= itemsLength) { + newIndex = 0; + } + + onIndexChange(newIndex); + }, + [currentIndex, items.length, onIndexChange], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowUp": + case "ArrowLeft": + e.preventDefault(); + moveBy(1); + break; + case "ArrowDown": + case "ArrowRight": + e.preventDefault(); + moveBy(-1); + break; + case "Home": + e.preventDefault(); + onIndexChange(0); + break; + case "End": + e.preventDefault(); + onIndexChange(items.length - 1); + break; + } + }, + [moveBy, onIndexChange, items.length], + ); + + return ( +
+ {children} +
+ ); +}); +WheelPickerSelect.displayName = "WheelPickerSelect"; + +// --------------------------------------------------------------------------------------------------------------------- +// WheelPickerOptions -------------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------------------------------- + +export type WheelPickerOptionsProps = React.HTMLAttributes; + +const WheelPickerOptions = React.forwardRef< + HTMLDivElement, + WheelPickerOptionsProps +>(({ className, ...props }, ref) => { + const { items, currentIndex, onIndexChange, theta, radius } = + useWheelPickerContext(); + + return ( +
+
+ {/* First placeholder */} + + + {/* Real items */} + {items.map((item, idx) => { + // Render index = item index + 1 (accounting for first placeholder) + const renderIdx = idx + 1; + const angle = theta * renderIdx; + const isSelected = idx === currentIndex; + return ( +
{ + if (!isSelected) { + e.preventDefault(); + e.stopPropagation(); + onIndexChange(idx); + } + }} + > + + {item} + +
+ ); + })} + + {/* Last placeholder */} + +
+
+ ); +}); +WheelPickerOptions.displayName = "WheelPickerOptions"; + +// --------------------------------------------------------------------------------------------------------------------- +// WheelPickerEmpty (placeholders) ------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------------------------------- + +type PlaceholderPosition = "first" | "last"; + +export interface WheelPickerEmptyProps + extends React.HTMLAttributes { + position: PlaceholderPosition; +} + +const WheelPickerEmpty = React.forwardRef< + HTMLDivElement, + WheelPickerEmptyProps +>(({ position, className, ...props }, ref) => { + const { theta, radius, count, currentIndex } = useWheelPickerContext(); + + const renderIdx = position === "first" ? 0 : count - 1; + const angle = theta * renderIdx; + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + }} + {...props} + /> + ); +}); +WheelPickerEmpty.displayName = "WheelPickerEmpty"; + +// --------------------------------------------------------------------------------------------------------------------- +// Exports ------------------------------------------------------------------------------------------------------------- +// --------------------------------------------------------------------------------------------------------------------- + +export { WheelPicker, WheelPickerSelect, WheelPickerOptions, WheelPickerEmpty }; diff --git a/apps/dashboard/src/components/content/action-card.tsx b/apps/dashboard/src/components/content/action-card.tsx new file mode 100644 index 000000000..bc70fe75d --- /dev/null +++ b/apps/dashboard/src/components/content/action-card.tsx @@ -0,0 +1,86 @@ +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +export function ActionCard({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + {children} + + ); +} + +export function ActionCardHeader({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + {children} + + ); +} + +export function ActionCardGroup({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function ActionCardTitle({ + children, + ...props +}: React.ComponentProps<"div">) { + return {children}; +} + +export function ActionCardDescription({ + children, + ...props +}: React.ComponentProps<"div">) { + return {children}; +} + +export function ActionCardContent({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + {children} + + ); +} + +export function ActionCardFooter({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/components/content/billing-overlay.tsx b/apps/dashboard/src/components/content/billing-overlay.tsx new file mode 100644 index 000000000..efcada504 --- /dev/null +++ b/apps/dashboard/src/components/content/billing-overlay.tsx @@ -0,0 +1,61 @@ +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function BillingOverlayContainer({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function BillingOverlay({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function BillingOverlayButton({ + children, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export function BillingOverlayDescription({ + children, + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ {children} +

+ ); +} diff --git a/apps/dashboard/src/components/content/billing-progress.tsx b/apps/dashboard/src/components/content/billing-progress.tsx new file mode 100644 index 000000000..c1729af29 --- /dev/null +++ b/apps/dashboard/src/components/content/billing-progress.tsx @@ -0,0 +1,23 @@ +import { Progress } from "@/components/ui/progress"; + +interface BillingProgressProps { + label: string; + value: number; + max: number; +} + +export function BillingProgress({ label, value, max }: BillingProgressProps) { + return ( +
+
+
+
{label}
+
+ {value}/{max} +
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/components/content/block-wrapper.tsx b/apps/dashboard/src/components/content/block-wrapper.tsx new file mode 100644 index 000000000..c072a61b9 --- /dev/null +++ b/apps/dashboard/src/components/content/block-wrapper.tsx @@ -0,0 +1,46 @@ +"use client"; + +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; + +export function BlockWrapper({ + className, + children, + ...props +}: React.HTMLAttributes) { + const [isOpened, setIsOpened] = React.useState(false); + + return ( + +
+ + {children} + + {!isOpened ? ( +
+ + + +
+ ) : null} +
+
+ ); +} diff --git a/apps/dashboard/src/components/content/empty-state.tsx b/apps/dashboard/src/components/content/empty-state.tsx new file mode 100644 index 000000000..38bb2a838 --- /dev/null +++ b/apps/dashboard/src/components/content/empty-state.tsx @@ -0,0 +1,46 @@ +import { cn } from "@/lib/utils"; + +export function EmptyStateContainer({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function EmptyStateTitle({ + children, + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ {children} +

+ ); +} + +export function EmptyStateDescription({ + children, + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ {children} +

+ ); +} diff --git a/apps/dashboard/src/components/content/process-message.tsx b/apps/dashboard/src/components/content/process-message.tsx new file mode 100644 index 000000000..791a1f8e9 --- /dev/null +++ b/apps/dashboard/src/components/content/process-message.tsx @@ -0,0 +1,34 @@ +import type { AnchorHTMLAttributes } from "react"; +import { Fragment, createElement } from "react"; +import { jsx, jsxs } from "react/jsx-runtime"; +import rehypeReact from "rehype-react"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { unified } from "unified"; + +export function ProcessMessage({ value }: { value: string }) { + const result = unified() + .use(remarkParse) + .use(remarkRehype) + .use(rehypeReact, { + createElement, + Fragment, + jsx, + jsxs, + components: { + a: (props: AnchorHTMLAttributes) => { + return ( + + ); + }, + } as { [key: string]: React.ComponentType }, + }) + .processSync(value).result; + + return result; +} diff --git a/apps/dashboard/src/components/content/section.tsx b/apps/dashboard/src/components/content/section.tsx new file mode 100644 index 000000000..5beffaf26 --- /dev/null +++ b/apps/dashboard/src/components/content/section.tsx @@ -0,0 +1,106 @@ +import { cn } from "@/lib/utils"; + +export function Section({ + children, + className, + ...props +}: React.ComponentProps<"section">) { + return ( +
+ {children} +
+ ); +} + +export function SectionHeader({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function SectionHeaderRow({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function SectionDescription({ + children, + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ {children} +

+ ); +} + +export function SectionTitle({ + children, + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ {children} +

+ ); +} + +export function SectionGroup({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function SectionGroupHeader({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function SectionGroupTitle({ + children, + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ {children} +

+ ); +} diff --git a/apps/dashboard/src/components/controls-filter/.gitkeep b/apps/dashboard/src/components/controls-filter/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/dashboard/src/components/controls-search/button-reset.tsx b/apps/dashboard/src/components/controls-search/button-reset.tsx new file mode 100644 index 000000000..ff782b117 --- /dev/null +++ b/apps/dashboard/src/components/controls-search/button-reset.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; + +export function ButtonReset({ only }: { only?: string[] }) { + const searchParams = useSearchParams(); + const router = useRouter(); + + // Determine if at least one parameter that should be reset is present (or any parameter if `only` is undefined) + const hasParamsToReset = only + ? only.some((key) => searchParams.has(key)) + : !!searchParams.toString(); + + if (!hasParamsToReset) return null; + + const handleClick = () => { + // Clone the current search params so we can mutate them + const params = new URLSearchParams(searchParams.toString()); + + if (only && only.length > 0) { + // Remove only the specified keys + only.forEach((key) => params.delete(key)); + const query = params.toString(); + router.push( + query + ? `${window.location.pathname}?${query}` + : window.location.pathname, + ); + } else { + // No `only` prop provided – remove all query parameters + router.push(window.location.pathname); + } + }; + + if (!hasParamsToReset) return null; + + return ( + + ); +} diff --git a/apps/dashboard/src/components/controls-search/command-region.tsx b/apps/dashboard/src/components/controls-search/command-region.tsx new file mode 100644 index 000000000..c034f675f --- /dev/null +++ b/apps/dashboard/src/components/controls-search/command-region.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { Link } from "@/components/common/link"; +import { + BillingOverlay, + BillingOverlayButton, + BillingOverlayDescription, +} from "@/components/content/billing-overlay"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { REGIONS } from "@/data/metrics.client"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { groupByContinent } from "@openstatus/utils"; +import { useQuery } from "@tanstack/react-query"; +import { Check, Lock } from "lucide-react"; +import { parseAsArrayOf, parseAsStringLiteral, useQueryState } from "nuqs"; + +export const parseRegions = (regions: (typeof REGIONS)[number][]) => + parseAsArrayOf( + parseAsStringLiteral(REGIONS.filter((region) => regions.includes(region))), + ).withDefault(regions as unknown as (typeof REGIONS)[number][]); + +export function CommandRegion({ + regions, +}: { + regions: (typeof REGIONS)[number][]; +}) { + const trpc = useTRPC(); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const [selectedRegions, setSelectedRegions] = useQueryState( + "regions", + parseRegions(regions), + ); + + const limited = workspace?.plan === "free"; + + return ( + + + + + + + + + + { + const items = document.querySelectorAll( + '[data-slot="command-item"][data-disabled="false"]', + ); + const codes: (typeof REGIONS)[number][] = []; + + items.forEach((item) => { + const code = item.getAttribute("data-value"); + if (code && code !== "select-all") { + codes.push(code as (typeof REGIONS)[number]); + } + }); + + if (codes.length === selectedRegions.length) { + setSelectedRegions([]); + } else { + setSelectedRegions(codes); + } + }} + value="select-all" + disabled={limited} + > + Toggle selection + + + + {Object.entries(groupByContinent).map( + ([continent, continentRegions]) => { + const allowedRegions = continentRegions.filter((region) => + regions.includes(region.code), + ); + + if (allowedRegions.length === 0) { + return null; + } + return ( + + {allowedRegions.map((region) => ( + { + setSelectedRegions((prev) => + prev.includes(region.code) + ? prev.filter((r) => r !== region.code) + : [...prev, region.code], + ); + }} + > + {region.flag} + {region.code} + + {region.location} + + + + ))} + + ); + }, + )} + No region found. + + + {limited ? ( + + + + + Upgrade + + + + Filter by region is only available on paid plans. + + + ) : null} + + + ); +} diff --git a/apps/dashboard/src/components/controls-search/command-tags.tsx b/apps/dashboard/src/components/controls-search/command-tags.tsx new file mode 100644 index 000000000..576b83f06 --- /dev/null +++ b/apps/dashboard/src/components/controls-search/command-tags.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { Check } from "lucide-react"; +import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs"; + +export function CommandTags() { + const trpc = useTRPC(); + const { data: tags } = useQuery(trpc.monitorTag.list.queryOptions()); + const [selectedTags, setSelectedTags] = useQueryState( + "tags", + parseAsArrayOf(parseAsString).withDefault([]).withOptions({ + shallow: false, + }), + ); + + return ( + + + + + + + + + + {tags?.map((tag) => ( + { + setSelectedTags((prev) => + prev.includes(tag.name) + ? prev.filter((r) => r !== tag.name) + : [...prev, tag.name], + ); + }} + > +
+ + {tag.name} +
+ +
+ ))} +
+ No tag found. +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/controls-search/dropdown-interval.tsx b/apps/dashboard/src/components/controls-search/dropdown-interval.tsx new file mode 100644 index 000000000..03662ffbb --- /dev/null +++ b/apps/dashboard/src/components/controls-search/dropdown-interval.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { INTERVALS } from "@/data/metrics.client"; +import { Check } from "lucide-react"; +import { parseAsNumberLiteral, useQueryState } from "nuqs"; + +const MAPPING = { + 5: "5 minutes", + 15: "15 minutes", + 30: "30 minutes", + 60: "1 hour", + 120: "2 hours", + 240: "4 hours", + 480: "8 hours", + 1440: "1 day", +} as const; + +const parseInterval = parseAsNumberLiteral(INTERVALS).withDefault(30); + +export function DropdownInterval() { + const [interval, setInterval] = useQueryState("interval", parseInterval); + + return ( + + + + + + + + Resolution + + {INTERVALS.map((item) => ( + setInterval(item)}> + {MAPPING[item]} + {interval === item ? ( + + ) : null} + + ))} + + + + ); +} diff --git a/apps/dashboard/src/components/controls-search/dropdown-percentile.tsx b/apps/dashboard/src/components/controls-search/dropdown-percentile.tsx new file mode 100644 index 000000000..120ecbb2e --- /dev/null +++ b/apps/dashboard/src/components/controls-search/dropdown-percentile.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { PERCENTILES } from "@/data/metrics.client"; +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; +import { parseAsStringLiteral, useQueryState } from "nuqs"; + +const parsePercentile = parseAsStringLiteral(PERCENTILES).withDefault("p50"); + +export function DropdownPercentile() { + const [percentile, setPercentile] = useQueryState( + "percentile", + parsePercentile, + ); + + return ( + + + + + + + + Percentile + + {PERCENTILES.map((item) => ( + setPercentile(item)} + className={cn("capitalize")} + > + {item} + {percentile === item ? ( + + ) : null} + + ))} + + + + ); +} diff --git a/apps/dashboard/src/components/controls-search/dropdown-period.tsx b/apps/dashboard/src/components/controls-search/dropdown-period.tsx new file mode 100644 index 000000000..3ccd85fe0 --- /dev/null +++ b/apps/dashboard/src/components/controls-search/dropdown-period.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { PERIODS } from "@/data/metrics.client"; +import { Check } from "lucide-react"; +import { parseAsStringLiteral, useQueryState } from "nuqs"; + +// TODO: where to move it? +export const PERIOD_VALUES = [ + { + value: "1d", + label: "Last day", + }, + { + value: "7d", + label: "Last 7 days", + }, + { + value: "14d", + label: "Last 14 days", + }, +] satisfies { value: (typeof PERIODS)[number]; label: string }[]; + +const parsePeriod = parseAsStringLiteral(PERIODS).withDefault("1d"); + +export function DropdownPeriod() { + const [period, setPeriod] = useQueryState("period", parsePeriod); + + return ( + + + + + + + + Period + + {PERIOD_VALUES.map(({ value, label }) => ( + setPeriod(value)}> + {label} + {period === value ? : null} + + ))} + + + + ); +} diff --git a/apps/dashboard/src/components/controls-search/dropdown-status.tsx b/apps/dashboard/src/components/controls-search/dropdown-status.tsx new file mode 100644 index 000000000..565ee5ec6 --- /dev/null +++ b/apps/dashboard/src/components/controls-search/dropdown-status.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { STATUS } from "@/data/metrics.client"; +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; +import { parseAsStringLiteral, useQueryState } from "nuqs"; + +const parseStatus = parseAsStringLiteral(STATUS); + +export function DropdownStatus() { + const [status, setStatus] = useQueryState("status", parseStatus); + + return ( + + + + + + + + Request Status + + {STATUS.map((item) => ( + setStatus(item)} + className={cn("capitalize")} + > + {item} + {status === item ? : null} + + ))} + + + + ); +} diff --git a/apps/dashboard/src/components/controls-search/dropdown-trigger.tsx b/apps/dashboard/src/components/controls-search/dropdown-trigger.tsx new file mode 100644 index 000000000..552b5f48a --- /dev/null +++ b/apps/dashboard/src/components/controls-search/dropdown-trigger.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { TRIGGER } from "@/data/metrics.client"; +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; +import { parseAsStringLiteral, useQueryState } from "nuqs"; + +const parseTrigger = parseAsStringLiteral(TRIGGER); + +export function DropdownTrigger() { + const [trigger, setTrigger] = useQueryState("trigger", parseTrigger); + + return ( + + + + + + + + Trigger + + {TRIGGER.map((item) => ( + setTrigger(item)} + className={cn("capitalize")} + > + {item === "cron" ? "Scheduled" : "API"} + {trigger === item ? : null} + + ))} + + + + ); +} diff --git a/apps/dashboard/src/components/controls-search/popover-date.tsx b/apps/dashboard/src/components/controls-search/popover-date.tsx new file mode 100644 index 000000000..f30722aa1 --- /dev/null +++ b/apps/dashboard/src/components/controls-search/popover-date.tsx @@ -0,0 +1,141 @@ +import { DatePicker } from "@/components/date-picker"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { formatDateRange } from "@/lib/formatter"; +import { endOfDay, startOfDay, subDays, subHours } from "date-fns"; +import { parseAsIsoDateTime, useQueryState } from "nuqs"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { DateRange } from "react-day-picker"; + +export function PopoverDate() { + const [open, setOpen] = useState(false); + const today = useRef(new Date()); + const [from, setFrom] = useQueryState( + "from", + parseAsIsoDateTime.withDefault(startOfDay(today.current)), + ); + const [to, setTo] = useQueryState( + "to", + parseAsIsoDateTime.withDefault(endOfDay(today.current)), + ); + const [range, setRange] = useState({ from, to }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const presets = useMemo( + () => [ + { + id: "today", + label: "Today", + values: { + from: startOfDay(today.current), + to: endOfDay(today.current), + }, + shortcut: "t", + }, + { + id: "yesterday", + label: "Yesterday", + values: { + from: startOfDay(subDays(today.current, 1)), + to: endOfDay(subDays(today.current, 1)), + }, + shortcut: "y", + }, + { + id: "lastHour", + label: "Last hour", + values: { + from: subHours(today.current, 1), + to: today.current, + }, + shortcut: "h", + }, + { + id: "last6Hours", + label: "Last 6 hours", + values: { + from: subHours(today.current, 5), + to: today.current, + }, + shortcut: "s", + }, + { + id: "last24Hours", + label: "Last 24 hours", + values: { + from: subHours(today.current, 23), + to: today.current, + }, + shortcut: "d", + }, + { + id: "last7Days", + label: "Last 7 days", + values: { + from: subDays(today.current, 6), + to: today.current, + }, + shortcut: "w", + }, + { + id: "last14Days", + label: "Last 14 days", + values: { + from: subDays(today.current, 13), + to: today.current, + }, + shortcut: "b", + }, + ], + [today], + ); + + // instead use `range` state + const selected = presets.find((period) => { + return ( + from.getTime() === period.values.from.getTime() && + to.getTime() === period.values.to.getTime() + ); + }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (!open) { + setFrom(range.from ?? null); + setTo(range.to ?? null); + } + }, [open]); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (!open) return; + + presets.map((preset) => { + if (preset.shortcut === e.key) { + setFrom(preset.values.from); + setTo(preset.values.to); + setRange({ from: preset.values.from, to: preset.values.to }); + } + }); + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [presets, open, setFrom, setTo]); + + return ( + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/data-table/audit-logs/columns.tsx b/apps/dashboard/src/components/data-table/audit-logs/columns.tsx new file mode 100644 index 000000000..395548160 --- /dev/null +++ b/apps/dashboard/src/components/data-table/audit-logs/columns.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; +import { TableCellDate } from "@/components/data-table/table-cell-date"; +import { config, metadata } from "@/data/audit-logs.client"; +import { cn } from "@/lib/utils"; +import type { RouterOutputs } from "@openstatus/api"; +import type { ColumnDef } from "@tanstack/react-table"; + +type AuditLog = RouterOutputs["tinybird"]["auditLog"]["data"][number]; + +export const columns: ColumnDef[] = [ + { + id: "icon", + accessorFn: (row) => row.action, + header: () => null, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = row.getValue("action"); + const { icon: Icon, color } = config[value as keyof typeof config]; + if (!Icon) return null; + return ; + }, + meta: { + headerClassName: "w-7", + }, + }, + { + accessorKey: "action", + header: "Action", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = row.getValue("action"); + const { title } = config[value as keyof typeof config]; + if (!title) return null; + return
{title}
; + }, + }, + { + accessorKey: "metadata", + header: "Information", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = row.getValue("metadata"); + if (!value) return null; + return ( +
+ {Object.entries(value) + .filter(([key, value]) => + metadata[key as keyof typeof metadata]?.visible(value), + ) + .map(([key, value]) => ( + + ))} +
+ ); + }, + }, + { + accessorKey: "timestamp", + header: "Timestamp", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = row.getValue("timestamp"); + if (value instanceof Date) { + return ( + + + + ); + } + const date = new Date(Number(value)); + if (Number.isNaN(date.getTime())) { + return
{String(value)}
; + } + + return ( + + + + ); + }, + }, +]; + +function Pill({ label, value }: { label: string; value?: string }) { + if (!value) return null; + return ( +
+
+ {label} +
+
+ {/* NOTE: if we have more number values, we might wanna change it */} + {value} +
+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/audit-logs/wrapper.tsx b/apps/dashboard/src/components/data-table/audit-logs/wrapper.tsx new file mode 100644 index 000000000..4395dfc72 --- /dev/null +++ b/apps/dashboard/src/components/data-table/audit-logs/wrapper.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { BlockWrapper } from "@/components/content/block-wrapper"; +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; + +import { columns } from "./columns"; + +export function AuditLogsWrapper({ + monitorId, + interval, +}: { + monitorId: string; + interval: number; +}) { + const trpc = useTRPC(); + + const { data: auditLogs, isLoading } = useQuery( + trpc.tinybird.auditLog.queryOptions({ monitorId, interval }), + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!auditLogs?.data || auditLogs.data.length === 0) { + return ( + + No audit logs + + No audit logs found for this monitor. + + + ); + } + + return ( + + + + ); +} + +export default AuditLogsWrapper; diff --git a/apps/dashboard/src/components/data-table/billing/data-table.tsx b/apps/dashboard/src/components/data-table/billing/data-table.tsx new file mode 100644 index 000000000..d548f298a --- /dev/null +++ b/apps/dashboard/src/components/data-table/billing/data-table.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { Check } from "lucide-react"; +import { Fragment, useTransition } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { config as featureGroups, plans } from "@/data/plans"; +import { getStripe } from "@/lib/stripe"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import type { WorkspacePlan } from "@openstatus/db/src/schema"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +const BASE_URL = + process.env.NODE_ENV === "production" + ? "https://app.openstatus.dev" + : "http://localhost:3000"; + +export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { + const trpc = useTRPC(); + const [isPending, startTransition] = useTransition(); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + + const checkoutSessionMutation = useMutation( + trpc.stripeRouter.getCheckoutSession.mutationOptions({ + onSuccess: async (data) => { + if (!data) return; + + const stripe = await getStripe(); + stripe?.redirectToCheckout({ sessionId: data.id }); + }, + }), + ); + + if (!workspace) return null; + + const filteredPlans = Object.values(plans).filter((plan) => + restrictTo ? restrictTo.includes(plan.id) : true, + ); + + return ( + + + A list to compare the different features by plan. + + + + + Features comparison + + {filteredPlans.map(({ id, ...plan }) => { + const isCurrentPlan = workspace.plan === id; + return ( + +
+
+

{plan.title}

+

+ {plan.description} +

+
+

+ {plan.price}€{" "} + + /month + +

+ +
+
+ ); + })} +
+
+ + {Object.entries(featureGroups).map( + ([groupKey, { label, features }]) => ( + + + + {label} + + + {features.map(({ value, label: featureLabel, monthly }) => ( + + +
+ {featureLabel} +
+
+ {filteredPlans.map((plan) => { + const limitValue = + plan.limits[value as keyof typeof plan.limits]; + + function renderContent() { + if (typeof limitValue === "boolean") { + return limitValue ? ( + + ) : ( + + ‐ + + ); + } + if (typeof limitValue === "number") { + return new Intl.NumberFormat("us") + .format(limitValue) + .toString(); + } + + // TODO: create a format function for this in @data/plans + if (value === "regions" && Array.isArray(limitValue)) { + return limitValue?.length ?? 0; + } + + if (Array.isArray(limitValue) && limitValue.length > 0) { + return limitValue[0]; + } + return limitValue; + } + + return ( + + {renderContent()} + {monthly ? "/mo" : ""} + + ); + })} +
+ ))} +
+ ), + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/dable-cell-skeleton.tsx b/apps/dashboard/src/components/data-table/dable-cell-skeleton.tsx new file mode 100644 index 000000000..b7238c5ca --- /dev/null +++ b/apps/dashboard/src/components/data-table/dable-cell-skeleton.tsx @@ -0,0 +1,9 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +export function TableCellSkeleton({ + className, + ...props +}: React.ComponentProps) { + return ; +} diff --git a/apps/dashboard/src/components/data-table/data-table-sheet.tsx b/apps/dashboard/src/components/data-table/data-table-sheet.tsx new file mode 100644 index 000000000..94b49323a --- /dev/null +++ b/apps/dashboard/src/components/data-table/data-table-sheet.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; + +// TODO: rename to DataTableViewer? + +export function DataTableSheetContent({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} + +export function DataTableSheetHeader({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} + +export function DataTableSheetFooter({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} + +export function DataTableSheetFooterInfo({ + children, + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ {children} +

+ ); +} + +export { + SheetTitle as DataTableSheetTitle, + SheetDescription as DataTableSheetDescription, + SheetTrigger as DataTableSheetTrigger, + Sheet as DataTableSheet, +}; diff --git a/apps/dashboard/src/components/data-table/incidents/columns.tsx b/apps/dashboard/src/components/data-table/incidents/columns.tsx new file mode 100644 index 000000000..e6f8a9a04 --- /dev/null +++ b/apps/dashboard/src/components/data-table/incidents/columns.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { TableCellDate } from "@/components/data-table/table-cell-date"; +import { TableCellLink } from "@/components/data-table/table-cell-link"; +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; +import type { RouterOutputs } from "@openstatus/api"; +import type { ColumnDef } from "@tanstack/react-table"; +import { formatDistanceStrict } from "date-fns"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type Incident = RouterOutputs["incident"]["list"][number]; + +export const columns: ColumnDef[] = [ + { + id: "monitor", + accessorFn: (row) => row.monitor.name, + header: "Monitor", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + return ( + + ); + }, + meta: { + cellClassName: "max-w-[150px] min-w-max", + }, + }, + { + accessorKey: "startedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + enableHiding: false, + }, + { + accessorKey: "acknowledgedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + enableHiding: false, + }, + { + accessorKey: "resolvedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => , + enableHiding: false, + }, + { + id: "duration", + accessorFn: (row) => + row.resolvedAt + ? formatDistanceStrict(row.startedAt, row.resolvedAt) + : "ongoing", + header: "Duration", + cell: ({ row }) => { + const value = row.getValue("duration"); + if (typeof value === "string") { + const [amount, unit] = value.split(" "); + return ; + } + return ; + }, + }, + { + id: "actions", + cell: ({ row }) => , + meta: { + cellClassName: "w-8", + }, + }, +]; diff --git a/apps/dashboard/src/components/data-table/incidents/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/incidents/data-table-row-actions.tsx new file mode 100644 index 000000000..50b5cea26 --- /dev/null +++ b/apps/dashboard/src/components/data-table/incidents/data-table-row-actions.tsx @@ -0,0 +1,152 @@ +"use client"; + +import type { Row } from "@tanstack/react-table"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { getActions } from "@/data/incidents.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { isTRPCClientError } from "@trpc/client"; +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; + +type Incident = RouterOutputs["incident"]["list"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const [isPending, startTransition] = useTransition(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const acknowledgeIncidentMutation = useMutation( + trpc.incident.acknowledge.mutationOptions({ + onSuccess: () => { + queryClient.refetchQueries({ + queryKey: trpc.incident.list.queryKey({ + monitorId: row.original.monitorId, + }), + }); + }, + }), + ); + const resolveIncidentMutation = useMutation( + trpc.incident.resolve.mutationOptions({ + onSuccess: () => { + queryClient.refetchQueries({ + queryKey: trpc.incident.list.queryKey({ + monitorId: row.original.monitorId, + }), + }); + }, + }), + ); + const deleteIncidentMutation = useMutation( + trpc.incident.delete.mutationOptions({ + onSuccess: () => { + queryClient.refetchQueries({ + queryKey: trpc.incident.list.queryKey({ + monitorId: row.original.monitorId, + }), + }); + }, + }), + ); + + const [type, setType] = useState<"acknowledge" | "resolve" | null>(null); + const open = useMemo(() => type !== null, [type]); + + const actions = getActions({ + acknowledge: row.original.acknowledgedAt + ? undefined + : () => setType("acknowledge"), + resolve: row.original.resolvedAt ? undefined : () => setType("resolve"), + }); + + const handleConfirm = async () => { + try { + startTransition(async () => { + const promise = + type === "acknowledge" + ? acknowledgeIncidentMutation.mutateAsync({ + id: row.original.id, + }) + : resolveIncidentMutation.mutateAsync({ + id: row.original.id, + }); + toast.promise(promise, { + loading: "Confirming...", + success: "Confirmed", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "Failed to confirm"; + }, + }); + await promise; + setType(null); + }); + } catch (error) { + console.error("Failed to confirm:", error); + } + }; + + return ( + <> + { + await deleteIncidentMutation.mutateAsync({ + id: row.original.id, + }); + }, + }} + /> + setType(null)}> + { + // NOTE: bug where the body is not clickable after closing the alert dialog + event.preventDefault(); + document.body.style.pointerEvents = ""; + }} + > + + Confirm your action + + You are about to {type}{" "} + this incident. + + + + Cancel + { + e.preventDefault(); + handleConfirm(); + }} + disabled={isPending} + > + {isPending ? "Confirming..." : "Confirm"} + + + + + + ); +} diff --git a/apps/dashboard/src/components/data-table/maintenances/columns.tsx b/apps/dashboard/src/components/data-table/maintenances/columns.tsx new file mode 100644 index 000000000..ef21abd68 --- /dev/null +++ b/apps/dashboard/src/components/data-table/maintenances/columns.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; +import type { RouterOutputs } from "@openstatus/api"; +import type { ColumnDef } from "@tanstack/react-table"; +import { formatDistanceStrict } from "date-fns"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type Maintenance = RouterOutputs["maintenance"]["list"][number]; + +export const columns: ColumnDef[] = [ + { + accessorKey: "title", + header: "Title", + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "message", + header: "Message", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = String(row.getValue("message")); + return ( +
+ {value} +
+ ); + }, + }, + { + accessorKey: "from", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("from"); + if (value instanceof Date) { + return ( +
{value.toLocaleString()}
+ ); + } + if (typeof value === "string") { + return
{value}
; + } + return
-
; + }, + enableHiding: false, + }, + { + id: "duration", + accessorFn: (row) => formatDistanceStrict(row.from, row.to), + header: "Duration", + cell: ({ row }) => { + const value = row.getValue("duration"); + if (typeof value === "string") { + const [amount, unit] = value.split(" "); + return ; + } + return ; + }, + }, + { + id: "actions", + cell: ({ row }) => , + meta: { + cellClassName: "w-8", + }, + }, +]; diff --git a/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx new file mode 100644 index 000000000..302eb09c6 --- /dev/null +++ b/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { FormSheetMaintenance } from "@/components/forms/maintenance/sheet"; +import { getActions } from "@/data/maintenances.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; +import { useRef } from "react"; + +type Maintenance = RouterOutputs["maintenance"]["list"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const trpc = useTRPC(); + const buttonRef = useRef(null); + const actions = getActions({ + edit: () => buttonRef.current?.click(), + }); + const { data: statusPage } = useQuery( + trpc.page.get.queryOptions({ id: row.original.pageId ?? 0 }), + ); + const queryClient = useQueryClient(); + const updateMaintenanceMutation = useMutation( + trpc.maintenance.update.mutationOptions({ + onSuccess: () => { + queryClient.refetchQueries({ + queryKey: trpc.maintenance.list.queryKey({ + pageId: row.original.pageId ?? undefined, + }), + }); + }, + }), + ); + + const deleteMaintenanceMutation = useMutation( + trpc.maintenance.delete.mutationOptions({ + onSuccess: () => { + queryClient.refetchQueries({ + queryKey: trpc.maintenance.list.queryKey({ + pageId: row.original.pageId ?? undefined, + }), + }); + }, + }), + ); + + return ( + <> + { + await deleteMaintenanceMutation.mutateAsync({ + id: row.original.id, + }); + }, + }} + /> + { + await updateMaintenanceMutation.mutateAsync({ + id: row.original.id, + title: values.title, + message: values.message, + startDate: values.startDate, + endDate: values.endDate, + monitors: values.monitors, + }); + }} + > + + + + ); +} diff --git a/apps/dashboard/src/components/data-table/monitors/columns.tsx b/apps/dashboard/src/components/data-table/monitors/columns.tsx new file mode 100644 index 000000000..c8489b993 --- /dev/null +++ b/apps/dashboard/src/components/data-table/monitors/columns.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { TableCellLink } from "@/components/data-table/table-cell-link"; +// import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +// import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; +import type { ColumnDef } from "@tanstack/react-table"; +import { DataTableRowActions } from "./data-table-row-actions"; + +import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; +import type { RouterOutputs } from "@openstatus/api"; +import { formatDistanceToNow } from "date-fns"; +import { TableCellSkeleton } from "../dable-cell-skeleton"; +import { TableCellDate } from "../table-cell-date"; +import { TableCellNumber } from "../table-cell-number"; +import { TableCellUnavailable } from "../table-cell-unavailable"; + +type Monitor = RouterOutputs["monitor"]["list"][number] & { + globalMetrics?: + | RouterOutputs["tinybird"]["globalMetrics"]["data"][number] + // NOTE: after loading the data, if the monitor has no metrics, the value will be `false` + | false; +}; + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( + + ); + }, + enableHiding: false, + meta: { + cellClassName: "max-w-[150px] min-w-max", + }, + }, + { + accessorKey: "url", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( + + ); + }, + enableHiding: true, + meta: { + cellClassName: "max-w-[150px] min-w-max", + }, + }, + { + accessorKey: "jobType", + header: "Type", + enableHiding: true, + }, + { + id: "status", + accessorFn: (row) => { + console.log(row); + return row.active ? row.status : "inactive"; + }, + header: "Status", + cell: ({ row }) => { + const value = String(row.getValue("status")); + + switch (value) { + case "active": + return
{value}
; + case "degraded": + return
{value}
; + case "error": + return
{value}
; + default: + return
{value}
; + } + }, + filterFn: (row, _, value) => { + if (Array.isArray(value)) { + if (value.includes("inactive")) { + return !row.original.active; + } + if (value.includes("active")) { + return !!row.original.active && row.original.status === "active"; + } + return value.includes(row.original.status); + } + return row.original.status === value; + }, + enableSorting: false, + enableHiding: false, + enableGlobalFilter: false, + }, + { + accessorKey: "active", + enableHiding: true, + enableGlobalFilter: false, + }, + { + accessorKey: "tags", + header: "Tags", + cell: ({ row }) => { + const value = row.getValue("tags"); + if (!Array.isArray(value)) return null; + if (value.length === 0) { + return
-
; + } + return ( +
+ {value.map((tag) => ( + +
+ {tag.name} + + ))} +
+ ); + }, + filterFn: (row, _, value) => { + const tagIds = row.original.tags.map((tag) => tag.id); + if (Array.isArray(value)) { + return value.some((v) => tagIds.includes(v)); + } + return tagIds.includes(value); + }, + getUniqueValues: (row) => row.tags.map((tag) => tag.id), + enableSorting: false, + enableHiding: false, + enableGlobalFilter: false, + }, + { + id: "lastIncident", + header: "Last Incident", + accessorFn: (row) => row.incidents?.[0]?.createdAt, + cell: ({ row }) => { + const value = row.getValue("lastIncident"); + return ; + }, + enableHiding: false, + enableGlobalFilter: false, + }, + { + id: "lastTimestamp", + header: "Last Checked", + accessorFn: (row) => + typeof row.globalMetrics === "object" + ? row.globalMetrics.lastTimestamp + : row.globalMetrics, + cell: ({ row }) => { + const value = row.getValue("lastTimestamp"); + if (value === undefined) return ; + return ( + + ); + }, + enableHiding: false, + enableGlobalFilter: false, + }, + { + id: "p50", + accessorFn: (row) => + typeof row.globalMetrics === "object" + ? row.globalMetrics.p50Latency + : row.globalMetrics, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("p50"); + if (value === undefined) return ; + if (!value) return ; + return ; + }, + enableHiding: false, + }, + { + id: "p90", + accessorFn: (row) => + typeof row.globalMetrics === "object" + ? row.globalMetrics.p90Latency + : row.globalMetrics, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("p90"); + if (value === undefined) return ; + if (!value) return ; + return ; + }, + enableHiding: false, + enableGlobalFilter: false, + }, + { + id: "p95", + accessorFn: (row) => + typeof row.globalMetrics === "object" + ? row.globalMetrics.p95Latency + : row.globalMetrics, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const value = row.getValue("p95"); + if (value === undefined) return ; + if (!value) return ; + return ; + }, + enableHiding: false, + enableGlobalFilter: false, + }, + { + id: "actions", + cell: ({ row }) => , + meta: { + cellClassName: "w-8", + }, + }, +]; diff --git a/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx b/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx new file mode 100644 index 000000000..3616183bc --- /dev/null +++ b/apps/dashboard/src/components/data-table/monitors/data-table-action-bar.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { SelectTrigger } from "@radix-ui/react-select"; +import type { Table } from "@tanstack/react-table"; +import { CheckCircle2, Trash2 } from "lucide-react"; +import * as React from "react"; + +import { + DataTableActionBar, + DataTableActionBarAction, + DataTableActionBarSelection, +} from "@/components/ui/data-table/data-table-action-bar"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { isTRPCClientError } from "@trpc/client"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Input } from "@/components/ui/input"; + +type Monitor = RouterOutputs["monitor"]["list"][number]; + +const ACTIVE = [ + { label: "active", value: true }, + { label: "inactive", value: false }, +]; + +interface MonitorDataTableActionBarProps { + table: Table; +} + +export function MonitorDataTableActionBar({ + table, +}: MonitorDataTableActionBarProps) { + const [open, setOpen] = React.useState(false); + const [isPending, startTransition] = React.useTransition(); + const [value, setValue] = React.useState(""); + const rows = table.getFilteredSelectedRowModel().rows; + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const deleteMonitorsMutation = useMutation( + trpc.monitor.deleteMonitors.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.monitor.list.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.workspace.get.queryKey(), + }); + // Clear selection once deletion succeeds + table.toggleAllRowsSelected(false); + }, + }), + ); + + const updateMonitorsMutation = useMutation( + trpc.monitor.updateMonitors.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.monitor.list.queryKey(), + }); + }, + }), + ); + + const confirmationValue = React.useMemo( + () => `delete monitor${rows.length === 1 ? "" : "s"}`, + [rows.length], + ); + + const handleDelete = async () => { + try { + startTransition(async () => { + const promise = deleteMonitorsMutation.mutateAsync({ + ids: rows.map((row) => row.original.id), + }); + toast.promise(promise, { + loading: "Deleting...", + success: "Deleted", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "Failed to delete"; + }, + }); + await promise; + setOpen(false); + }); + } catch (error) { + console.error("Failed to delete:", error); + } + }; + + return ( + 0}> + + +
+ + + + + + + + { + // Work-around: body becomes unclickable after closing the dialog + event.preventDefault(); + document.body.style.pointerEvents = ""; + }} + > + + + Delete {rows.length} monitor{rows.length > 1 ? "s" : ""}? + + + This action cannot be undone. This will permanently remove the + selected monitor(s) from the database. + + +
+

+ Please write ' + {confirmationValue} + ' to confirm +

+ setValue(e.target.value)} /> +
+ + e.stopPropagation()}> + Cancel + + { + e.preventDefault(); + handleDelete(); + }} + > + {isPending ? "Deleting..." : "Delete"} + + +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx new file mode 100644 index 000000000..769cb444c --- /dev/null +++ b/apps/dashboard/src/components/data-table/monitors/data-table-row-actions.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ExportCodeDialog } from "@/components/dialogs/export-code"; +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { getActions } from "@/data/monitors.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; + +type Monitor = RouterOutputs["monitor"]["list"][number]; +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const [openDialog, setOpenDialog] = useState(false); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const deleteMonitorMutation = useMutation( + trpc.monitor.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries(trpc.monitor.list.queryOptions()); + }, + }), + ); + const router = useRouter(); + const actions = getActions({ + edit: () => router.push(`/monitors/${row.original.id}/edit`), + "copy-id": () => { + navigator.clipboard.writeText("ID"); + toast.success("Monitor ID copied to clipboard"); + }, + // export: () => setOpenDialog(true), + }); + + return ( + <> + { + await deleteMonitorMutation.mutateAsync({ + id: row.original.id, + }); + }, + }} + /> + + + ); +} diff --git a/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx b/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx new file mode 100644 index 000000000..6b92ab600 --- /dev/null +++ b/apps/dashboard/src/components/data-table/monitors/data-table-toolbar.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { Table } from "@tanstack/react-table"; +import { X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; +import type { RouterOutputs } from "@openstatus/api"; + +type Monitor = RouterOutputs["monitor"]["list"][number]; +type MonitorTag = RouterOutputs["monitorTag"]["list"][number]; + +export interface MonitorDataTableToolbarProps { + table: Table; + tags: MonitorTag[]; +} + +export function MonitorDataTableToolbar({ + table, + tags, +}: MonitorDataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + return ( +
+
+ table.setGlobalFilter(event.target.value)} + className="h-8 w-[150px] lg:w-[250px]" + /> + {table.getColumn("tags") && ( + ({ + label: tag.name, + value: tag.id, + }))} + /> + )} + {isFiltered && ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/notifications/columns.tsx b/apps/dashboard/src/components/data-table/notifications/columns.tsx new file mode 100644 index 000000000..d5ed03736 --- /dev/null +++ b/apps/dashboard/src/components/data-table/notifications/columns.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; +import { type NotifierProvider, config } from "@/data/notifications.client"; +import type { RouterOutputs } from "@openstatus/api"; +import type { ColumnDef } from "@tanstack/react-table"; +import { TableCellBadge } from "../table-cell-badge"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type Notifier = RouterOutputs["notification"]["list"][number]; + +export const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: ({ column }) => ( + + ), + enableHiding: false, + }, + { + accessorKey: "provider", + header: "Provider", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const provider = row.getValue("provider") as NotifierProvider; + const Icon = config[provider].icon; + return ( + + + {config[provider].label} + + ); + }, + }, + { + accessorKey: "monitors", + header: "Monitors", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = row.getValue("monitors"); + if (Array.isArray(value) && value.length > 0 && "name" in value[0]) { + return ( +
+ {value.map((m) => ( + + ))} +
+ ); + } + return -; + }, + meta: { + cellClassName: "tabular-nums font-mono", + }, + }, + { + id: "actions", + cell: ({ row }) => , + meta: { + cellClassName: "w-8", + }, + }, +]; diff --git a/apps/dashboard/src/components/data-table/notifications/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/notifications/data-table-row-actions.tsx new file mode 100644 index 000000000..9f091cb17 --- /dev/null +++ b/apps/dashboard/src/components/data-table/notifications/data-table-row-actions.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { FormSheetNotifier } from "@/components/forms/notifications/sheet"; +import { getActions } from "@/data/notifications.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; +import { useRef } from "react"; + +type Notifier = RouterOutputs["notification"]["list"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions(props: DataTableRowActionsProps) { + const buttonRef = useRef(null); + const actions = getActions({ + edit: () => buttonRef.current?.click(), + }); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); + const updateNotifierMutation = useMutation( + trpc.notification.updateNotifier.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.notification.list.queryKey(), + }); + }, + }), + ); + const deleteNotifierMutation = useMutation( + trpc.notification.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.notification.list.queryKey(), + }); + }, + }), + ); + + return ( + <> + { + await deleteNotifierMutation.mutateAsync({ + id: props.row.original.id, + }); + }, + }} + /> + m.id), + }} + monitors={monitors ?? []} + onSubmit={async (values) => { + await updateNotifierMutation.mutateAsync({ + id: props.row.original.id, + name: values.name, + data: + typeof values.data === "string" + ? { [values.provider]: values.data } + : values.data, + monitors: values.monitors, + }); + }} + > + + + + ); +} diff --git a/apps/dashboard/src/components/data-table/response-logs/columns.tsx b/apps/dashboard/src/components/data-table/response-logs/columns.tsx new file mode 100644 index 000000000..b62320355 --- /dev/null +++ b/apps/dashboard/src/components/data-table/response-logs/columns.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; +import { TableCellDate } from "@/components/data-table/table-cell-date"; +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { getStatusCodeVariant, textColors } from "@/data/status-codes"; +import { cn } from "@/lib/utils"; +import type { RouterOutputs } from "@openstatus/api"; +import { flyRegionsDict } from "@openstatus/utils"; +import { HoverCardPortal } from "@radix-ui/react-hover-card"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Clock, Workflow } from "lucide-react"; + +type ResponseLog = RouterOutputs["tinybird"]["list"]["data"][number]; + +export const columns: ColumnDef[] = [ + { + accessorKey: "requestStatus", + header: () => null, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = row.getValue("requestStatus"); + if (value === "error") { + return
; + } + if (value === "degraded") { + return
; + } + if (value === "success") { + return
; + } + return
-
; + }, + }, + { + accessorKey: "timestamp", + header: "Timestamp", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = new Date(row.getValue("timestamp")); + return ( + + + + ); + }, + }, + { + accessorKey: "statusCode", + header: "Status", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const log = row.original; + if (log.type === "http") { + const value = log.statusCode; + const variant = getStatusCodeVariant(value); + return ( + + ); + } + return
-
; + }, + }, + { + accessorKey: "latency", + header: "Latency", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + return ; + }, + }, + { + accessorKey: "region", + header: "Region", + cell: ({ row }) => { + const value = row.getValue("region"); + + if (typeof value !== "string") { + return
-
; + } + + const regionConfig = flyRegionsDict[value as keyof typeof flyRegionsDict]; + return regionConfig.location; + }, + enableSorting: false, + enableHiding: false, + filterFn: "arrIncludesSome", + meta: { + cellClassName: "text-muted-foreground font-mono", + }, + }, + { + accessorKey: "timing", + header: "Timing", + cell: ({ row }) => { + const log = row.original; + if (log.type === "http" && log.timing) { + return ; + } + return
-
; + }, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "trigger", + header: "Trigger", + cell: ({ row }) => { + const value = row.getValue("trigger"); + if (value === "cron" || value === "api") { + const Icon = value === "cron" ? Clock : Workflow; + const label = value === "cron" ? "Scheduled" : "API"; + return ( + + + + + + +

{label}

+
+
+
+ ); + } + return
-
; + }, + enableSorting: false, + enableHiding: false, + meta: { + cellClassName: "font-mono", + headerClassName: "sr-only", + }, + }, +]; + +function HoverCardTiming({ + timing, + latency, +}: { + timing: NonNullable["timing"]>; + latency: number; +}) { + return ( + + +
+ {Object.entries(timing).map(([key, value], index) => ( +
+ ))} +
+ + {/* REMINDER: allows us to port the content to the document.body, which is helpful when using opacity-50 on the row element */} + + + + + + + ); +} + +function HoverCardTimingContent({ + timing, + latency, +}: { + timing: NonNullable["timing"]>; + latency: number; +}) { + return ( +
+ {Object.entries(timing).map(([key, value], index) => { + return ( +
+
+
+
+ {key} +
+
+
+
+ {`${new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + }).format((value / latency) * 100)}%`} +
+
+ {new Intl.NumberFormat("en-US", { + maximumFractionDigits: 3, + }).format(value)} + ms +
+
+
+ ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx new file mode 100644 index 000000000..91806fd7e --- /dev/null +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx @@ -0,0 +1,386 @@ +"use client"; + +import { TableCellDate } from "@/components/data-table/table-cell-date"; +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getStatusCodeVariant, textColors } from "@/data/status-codes"; +import { formatMilliseconds, formatPercentage } from "@/lib/formatter"; +import { cn } from "@/lib/utils"; +import type { RouterOutputs } from "@openstatus/api"; +import { flyRegionsDict } from "@openstatus/utils"; +import { Braces, TableProperties } from "lucide-react"; + +type ResponseLog = RouterOutputs["tinybird"]["get"]["data"][number]; + +export function DataTableBasics({ data }: { data: ResponseLog }) { + if (data.type === "http") { + return ; + } + if (data.type === "tcp") { + return ; + } + return null; +} + +export function DataTableBasicsHTTP({ + data, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; +}) { + const regionConfig = flyRegionsDict[data.region]; + return ( + + + + + + + + Request + + + + Result + + {/* TODO: add colored square like list (see columns) */} + +
+
+
+ {data?.requestStatus ?? "unknown"} +
+
+ + + {data.id ? ( + + + ID + + + {data.id} + + + ) : null} + + + Timestamp + + + + + + + + URL + + + {data.url} + + + {/* TODO: store method in TB 🤦 */} + {/* + + Method + + + {data?.method} + + */} + + + Status + + + + + + + + Latency + + + + + + + + Region + + + {regionConfig?.flag} {regionConfig?.code}{" "} + + {regionConfig?.location} + + + + {data.trigger ? ( + + + Trigger + + + {data?.trigger} + + + ) : null} + {data.headers ? ( + <> + + Headers + + + + + + + + + + + + + +
+ + + + + + {Object.entries(data?.headers ?? {}).map( + ([key, value]) => ( + + + {key} + + + {value} + + + ), + )} + +
+ + +
+                      {JSON.stringify(data?.headers, null, 2)}
+                    
+
+ + + + + ) : null} + {data.timing ? ( + <> + + Timing + + {Object.entries(data?.timing ?? {}).map(([key, value], index) => ( + + + {key} + + +
+
+ + {formatPercentage(value / (data?.latency || 100))} + +
+
+ + {formatMilliseconds(value)} + +
+
+
+ + + ))} + + ) : null} + {data?.message ? ( + <> + + Message + + + +
+                  {data.message}
+                
+
+
+ + ) : null} + {data.assertions ? ( + <> + + Assertions + + + + {!data.assertions || data.assertions === "[]" ? ( +
+ Default status code 2xx assertion +
+ ) : ( +
+                    {JSON.stringify(data.assertions, null, 2)}
+                  
+ )} +
+
+ + ) : null} + + + ); +} + +export function DataTableBasicsTCP({ + data, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; +}) { + const regionConfig = flyRegionsDict[data.region]; + return ( + + + + + + + + Request + + + + Result + + {/* TODO: add colored square like list (see columns) */} + +
+
+
+ {data?.requestStatus ?? "unknown"} +
+
+ + + {data.id ? ( + + + ID + + + {data.id} + + + ) : null} + + + Timestamp + + + + + + + + URI + + + {data.uri} + + + + + Latency + + + + + + + + Region + + + {regionConfig?.flag} {regionConfig?.code}{" "} + + {regionConfig?.location} + + + + {data.trigger ? ( + + + Trigger + + + {data?.trigger} + + + ) : null} + {data?.errorMessage ? ( + <> + + Error Message + + + +
+                  {data.errorMessage}
+                
+
+
+ + ) : null} + +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx new file mode 100644 index 000000000..243f3445a --- /dev/null +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { + DataTableSheet, + DataTableSheetContent, + DataTableSheetHeader, + DataTableSheetTitle, +} from "@/components/data-table/data-table-sheet"; +import type { RouterOutputs } from "@openstatus/api"; +import { DataTableBasics } from "./data-table-basics"; + +type TestTCP = RouterOutputs["checker"]["testTcp"]; +type TestHTTP = RouterOutputs["checker"]["testHttp"]; +type Monitor = NonNullable; + +export function DataTableSheetTest({ + data, + monitor, + onClose, +}: { + data: TestTCP | TestHTTP | null; + monitor: Monitor; + onClose: () => void; +}) { + if (!data) return null; + + return ( + + {/* NOTE: we are using onCloseAutoFocus to reset with a delay to avoid abrupt closing of the sheet */} + + + Test Result + + + + + ); +} diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx new file mode 100644 index 000000000..0b51f4057 --- /dev/null +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { + DataTableSheet, + DataTableSheetContent, + DataTableSheetFooter, + DataTableSheetHeader, + DataTableSheetTitle, +} from "@/components/data-table/data-table-sheet"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import type { RouterOutputs } from "@openstatus/api"; +import { Check, Copy } from "lucide-react"; +import { DataTableBasics } from "./data-table-basics"; + +type ResponseLog = RouterOutputs["tinybird"]["get"]["data"][number]; + +export function Sheet({ + data, + onClose, +}: { + data: ResponseLog | null; + onClose: () => void; +}) { + const { copy, isCopied } = useCopyToClipboard(); + if (!data) return null; + + return ( + !open && onClose()}> + + + Response Logs + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx new file mode 100644 index 000000000..7f4b81fb4 --- /dev/null +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-toolbar.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { Table } from "@tanstack/react-table"; +import { X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; + +import { DataTableFacetedFilter } from "@/components/ui/data-table/data-table-faceted-filter"; +import { regions } from "@/data/regions"; +import { statusCodes } from "@/data/status-codes"; +import type { RouterOutputs } from "@openstatus/api"; + +type ResponseLog = RouterOutputs["tinybird"]["list"]["data"][number]; + +export interface ResponseLogsDataTableToolbarProps { + table: Table; +} + +export function ResponseLogsDataTableToolbar({ + table, +}: ResponseLogsDataTableToolbarProps) { + const isFiltered = table.getState().columnFilters.length > 0; + + return ( +
+
+ {table.getColumn("status") && ( + ({ + label: code.code.toString(), + value: code.code.toString(), + }))} + /> + )} + {table.getColumn("region") && ( + ({ + label: region.location, + value: region.code, + }))} + /> + )} + {table.getColumn("error") && ( + + )} + {isFiltered && ( + + )} +
+ {/* */} +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/response-logs/regions/columns.tsx b/apps/dashboard/src/components/data-table/response-logs/regions/columns.tsx new file mode 100644 index 000000000..7bf11d107 --- /dev/null +++ b/apps/dashboard/src/components/data-table/response-logs/regions/columns.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { ChartLineRegion } from "@/components/chart/chart-line-region"; +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { RegionMetric } from "@/data/region-metrics"; +import { getActions } from "@/data/region-metrics.client"; +import { flyRegionsDict } from "@openstatus/utils"; +import type { ColumnDef } from "@tanstack/react-table"; +// import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +function TrendCell({ trend }: { trend: RegionMetric["trend"] }) { + return ; +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "region", + header: "Region", + cell: ({ row }) => { + const value = row.getValue("region"); + if (typeof value === "string") { + const region = flyRegionsDict[value as keyof typeof flyRegionsDict]; + return ( + + + + {region.flag} {region.code} + + {region.location} + + + ); + } + return null; + }, + enableSorting: false, + enableHiding: false, + meta: { + cellClassName: "w-24 font-mono", + }, + }, + { + accessorKey: "trend", + header: "Trend", + cell: ({ row }) => { + return ; + }, + enableSorting: false, + enableHiding: false, + meta: { + cellClassName: "w-full min-w-[200px] max-w-full", + }, + }, + { + accessorKey: "p50", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ; + }, + enableHiding: false, + meta: { + cellClassName: "w-12", + }, + }, + { + accessorKey: "p90", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ; + }, + enableHiding: false, + meta: { + cellClassName: "w-12", + }, + }, + { + accessorKey: "p99", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ; + }, + enableHiding: false, + meta: { + cellClassName: "w-12", + }, + }, + { + id: "actions", + cell: ({ row }) => { + // NOTE: works, but is not very react-esque + // eslint-disable-next-line react-hooks/rules-of-hooks + const router = useRouter(); + const actions = getActions({ + filter: async () => { + router.push(`?regions=${row.original.region}`); + }, + // TODO: add triggerById in TRPC client + // trigger: async () => { + // console.log(row.original); + // const promise = new Promise((resolve) => setTimeout(resolve, 1000)); + // toast.promise(promise, { + // loading: "Checking...", + // success: "Success", + // error: "Failed", + // }); + // await promise; + // }, + }); + return ; + }, + meta: { + headerClassName: "w-12", + cellClassName: "text-right", + }, + }, +]; 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 new file mode 100644 index 000000000..46f4d3eb0 --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx @@ -0,0 +1,29 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@/components/ui/table"; +import type { RouterOutputs } from "@openstatus/api"; + +type ApiKey = RouterOutputs["apiKey"]["get"]; + +export function DataTable({ apiKey }: { apiKey: ApiKey }) { + return ( + + + + Created At + + {new Date(apiKey.createdAt).toLocaleDateString()} + + + + Token + {apiKey.start}... + + +
+ ); +} 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 new file mode 100644 index 000000000..1ce60f83d --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/invitations/data-table.tsx @@ -0,0 +1,85 @@ +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatDate } from "@/lib/formatter"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +export function DataTable() { + const trpc = useTRPC(); + const { data: invitations, refetch } = useQuery( + trpc.invitation.list.queryOptions(), + ); + const deleteInvitationMutation = useMutation( + trpc.invitation.delete.mutationOptions({ + onSuccess: () => refetch(), + }), + ); + + if (!invitations) return null; + + if (invitations.length === 0) { + return ( + + No pending invitations + + Only active invitations are shown here. + + + ); + } + + return ( + + + + Email + Role + Created At + Expires At + Accepted At + + Actions + + + + + {invitations.map((item) => ( + + {item.email} + {item.role} + + {item.createdAt ? formatDate(item.createdAt) : "-"} + + {formatDate(item.expiresAt)} + + {item.acceptedAt ? formatDate(item.acceptedAt) : "-"} + + +
+ + deleteInvitationMutation.mutateAsync({ id: item.id }), + }} + /> +
+
+
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/settings/members/data-table.tsx b/apps/dashboard/src/components/data-table/settings/members/data-table.tsx new file mode 100644 index 000000000..10d2f8c88 --- /dev/null +++ b/apps/dashboard/src/components/data-table/settings/members/data-table.tsx @@ -0,0 +1,69 @@ +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatDate } from "@/lib/formatter"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +export function DataTable() { + const trpc = useTRPC(); + const { data: members, refetch } = useQuery(trpc.member.list.queryOptions()); + const deleteMemberMutation = useMutation( + trpc.member.delete.mutationOptions({ + onSuccess: () => refetch(), + }), + ); + + if (!members) return null; + + return ( + + + + Name + Email + Role + Created + + Actions + + + + + {members.map((item) => ( + + + {item.user.name ?? ( + - + )} + + {item.user.email} + {item.role} + {formatDate(item.createdAt)} + +
+ + await deleteMemberMutation.mutateAsync({ + id: item.user.id, + }), + }} + /> +
+
+
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/status-pages/columns.tsx b/apps/dashboard/src/components/data-table/status-pages/columns.tsx new file mode 100644 index 000000000..2fc4e605c --- /dev/null +++ b/apps/dashboard/src/components/data-table/status-pages/columns.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { Link } from "@/components/common/link"; +import { TableCellLink } from "@/components/data-table/table-cell-link"; +import type { RouterOutputs } from "@openstatus/api"; +import type { ColumnDef } from "@tanstack/react-table"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type StatusPage = RouterOutputs["page"]["list"][number]; + +export const columns: ColumnDef[] = [ + { + accessorKey: "title", + header: "Title", + cell: ({ row }) => { + return ( + + ); + }, + enableSorting: false, + enableHiding: false, + meta: { + cellClassName: "max-w-[150px] min-w-max", + }, + }, + { + accessorKey: "icon", + header: "Favicon", + cell: ({ row }) => { + const value = row.getValue("icon"); + if (!value || typeof value !== "string") + return -; + return ( + {`Favicon + ); + }, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "slug", + header: "Slug", + cell: ({ row }) => { + const domain = row.getValue("domain"); + const slug = row.getValue("slug"); + return ( + + ); + }, + enableSorting: false, + enableHiding: false, + meta: { + cellClassName: "font-mono", + }, + }, + { + accessorKey: "domain", + header: "Domain", + cell: ({ row }) => { + const value = row.getValue("domain"); + if (typeof value !== "string") + return -; + return ( + + {value} + + ); + }, + enableSorting: false, + enableHiding: false, + }, + { + id: "actions", + cell: ({ row }) => , + meta: { + cellClassName: "w-8", + }, + }, +]; diff --git a/apps/dashboard/src/components/data-table/status-pages/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/status-pages/data-table-row-actions.tsx new file mode 100644 index 000000000..71205a337 --- /dev/null +++ b/apps/dashboard/src/components/data-table/status-pages/data-table-row-actions.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { getActions } from "@/data/status-pages.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; + +type StatusPage = RouterOutputs["page"]["list"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const router = useRouter(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const deleteStatusPageMutation = useMutation( + trpc.page.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, + }), + ); + const actions = getActions({ + edit: () => router.push(`/status-pages/${row.original.id}/edit`), + "copy-id": () => { + navigator.clipboard.writeText("ID"); + toast.success("Monitor ID copied to clipboard"); + }, + }); + + return ( + { + await deleteStatusPageMutation.mutateAsync({ + id: row.original.id, + }); + }, + }} + /> + ); +} diff --git a/apps/dashboard/src/components/data-table/status-report-updates/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/status-report-updates/data-table-row-actions.tsx new file mode 100644 index 000000000..1ba029261 --- /dev/null +++ b/apps/dashboard/src/components/data-table/status-report-updates/data-table-row-actions.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; +import { getActions } from "@/data/status-report-updates.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { useRef } from "react"; + +type StatusReportUpdate = + RouterOutputs["statusReport"]["list"][number]["updates"][number]; + +interface DataTableRowActionsProps { + row: StatusReportUpdate; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const buttonRef = useRef(null); + const { id } = useParams<{ id: string }>(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const updateStatusReportUpdateMutation = useMutation( + trpc.statusReport.updateStatusReportUpdate.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.statusReport.list.queryKey({ + pageId: Number.parseInt(id), + }), + }); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, + }), + ); + const deleteStatusReportUpdateMutation = useMutation( + trpc.statusReport.deleteUpdate.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.statusReport.list.queryKey({ + pageId: Number.parseInt(id), + }), + }); + }, + }), + ); + const actions = getActions({ + edit: () => buttonRef.current?.click(), + }); + + return ( + <> + { + await deleteStatusReportUpdateMutation.mutateAsync({ + id: row.id, + }); + }, + }} + /> + { + await updateStatusReportUpdateMutation.mutateAsync({ + id: row.id, + statusReportId: row.statusReportId, + message: values.message, + status: values.status, + date: values.date, + }); + }} + > + + + + ); +} diff --git a/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx b/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx new file mode 100644 index 000000000..12a142581 --- /dev/null +++ b/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { ProcessMessage } from "@/components/content/process-message"; +import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { icons } from "@/data/icons"; +import { colors } from "@/data/status-report-updates.client"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Plus } from "lucide-react"; +import { useParams } from "next/navigation"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type StatusReportUpdates = + RouterOutputs["statusReport"]["list"][number]["updates"]; + +export function DataTable({ + updates, + reportId, +}: { + updates: StatusReportUpdates; + reportId: number; +}) { + const trpc = useTRPC(); + const { id } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + const sendStatusReportUpdateMutation = useMutation( + trpc.emailRouter.sendStatusReport.mutationOptions(), + ); + const createStatusReportUpdateMutation = useMutation( + trpc.statusReport.createStatusReportUpdate.mutationOptions({ + onSuccess: (update) => { + // TODO: move to server + if (update) { + sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); + } + // + queryClient.invalidateQueries({ + queryKey: trpc.statusReport.list.queryKey({ + pageId: Number.parseInt(id), + }), + }); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, + }), + ); + + return ( + + + + + Status + + Message + Date + + + + { + await createStatusReportUpdateMutation.mutateAsync({ + statusReportId: reportId, + message: values.message, + status: values.status, + date: values.date, + }); + }} + > + + + + + + Create Report Update + + + + + + + + {updates.map((update) => { + const Icon = icons.status[update.status]; + return ( + + +
+ +
+
+ +
+ +
+
+ + {update.date.toLocaleString()} + + + + +
+ ); + })} +
+
+ ); +} diff --git a/apps/dashboard/src/components/data-table/status-reports/columns.tsx b/apps/dashboard/src/components/data-table/status-reports/columns.tsx new file mode 100644 index 000000000..bfa205db1 --- /dev/null +++ b/apps/dashboard/src/components/data-table/status-reports/columns.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { TableCellBadge } from "@/components/data-table/table-cell-badge"; +import { TableCellDate } from "@/components/data-table/table-cell-date"; +import { TableCellNumber } from "@/components/data-table/table-cell-number"; +import { Button } from "@/components/ui/button"; +import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; +import { colors } from "@/data/status-report-updates.client"; +import { cn } from "@/lib/utils"; +import type { ColumnDef } from "@tanstack/react-table"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { DataTableRowActions } from "./data-table-row-actions"; + +import type { RouterOutputs } from "@openstatus/api"; + +type StatusReport = RouterOutputs["statusReport"]["list"][number]; + +export const columns: ColumnDef[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => { + return row.getCanExpand() ? ( + + ) : undefined; + }, + meta: { + headerClassName: "w-7", + }, + }, + { + accessorKey: "title", + header: "Title", + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "status", + header: "Current Status", + cell: ({ row }) => { + const value = String(row.getValue("status")); + return ( +
+ {value} +
+ ); + }, + enableSorting: false, + enableHiding: false, + }, + { + id: "updates", + accessorFn: (row) => row.updates.length, + header: "Total Updates", + cell: ({ row }) => { + const value = row.getValue("updates"); + return ; + }, + }, + { + id: "monitors", + accessorFn: (row) => row.monitors, + header: "Affected", + cell: ({ row }) => { + const value = row.getValue("monitors"); + if (Array.isArray(value) && value.length > 0 && "name" in value[0]) { + return ( +
+ {value.map((m) => ( + + ))} +
+ ); + } + return
-
; + }, + }, + { + id: "startedAt", + accessorFn: (row) => + row.updates.sort((a, b) => a.date.getTime() - b.date.getTime())[0]?.date, + header: ({ column }) => ( + + ), + cell: ({ row }) => , + enableHiding: false, + meta: { + cellClassName: "w-[170px]", + }, + }, + { + id: "actions", + cell: ({ row }) => , + meta: { + cellClassName: "w-8", + }, + }, +]; diff --git a/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx new file mode 100644 index 000000000..3b7bad095 --- /dev/null +++ b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; +import { FormSheetStatusReport } from "@/components/forms/status-report/sheet"; +import { getActions } from "@/data/status-reports.client"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; +import { useParams } from "next/navigation"; +import { useRef } from "react"; + +type StatusReport = RouterOutputs["statusReport"]["list"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const buttonCreateRef = useRef(null); + const buttonUpdateRef = useRef(null); + const actions = getActions({ + "create-update": () => buttonCreateRef.current?.click(), + edit: () => buttonUpdateRef.current?.click(), + "view-report": () => { + if (typeof window !== "undefined") { + window.open( + `https://${row.original.page.customDomain || `${row.original.page.slug}.openstatus.dev/events/report/${row.original.id}`}`, + "_blank", + ); + } + }, + }); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const { id } = useParams<{ id: string }>(); + const { data: monitors } = useQuery(trpc.monitor.list.queryOptions()); + const sendStatusReportUpdateMutation = useMutation( + trpc.emailRouter.sendStatusReport.mutationOptions(), + ); + const updateStatusReportMutation = useMutation( + trpc.statusReport.updateStatus.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.statusReport.list.queryKey({ + pageId: Number.parseInt(id), + }), + }); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, + }), + ); + const createStatusReportUpdateMutation = useMutation( + trpc.statusReport.createStatusReportUpdate.mutationOptions({ + onSuccess: (update) => { + // TODO: move to server + if (update) { + sendStatusReportUpdateMutation.mutateAsync({ id: update.id }); + } + // + queryClient.invalidateQueries({ + queryKey: trpc.statusReport.list.queryKey({ + pageId: Number.parseInt(id), + }), + }); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, + }), + ); + const deleteStatusReportMutation = useMutation( + trpc.statusReport.delete.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.statusReport.list.queryKey({ + pageId: Number.parseInt(id), + }), + }); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, + }), + ); + + if (!monitors) return null; + + return ( + <> + { + await deleteStatusReportMutation.mutateAsync({ + id: row.original.id, + }); + }, + }} + /> + m.id), + }} + onSubmit={async (values) => { + await updateStatusReportMutation.mutateAsync({ + id: row.original.id, + monitors: values.monitors, + title: values.title, + status: values.status, + }); + }} + > + + + { + await createStatusReportUpdateMutation.mutateAsync({ + statusReportId: row.original.id, + message: values.message, + status: values.status, + date: values.date, + }); + }} + > + + + + ); +} diff --git a/apps/dashboard/src/components/data-table/subscribers/columns.tsx b/apps/dashboard/src/components/data-table/subscribers/columns.tsx new file mode 100644 index 000000000..c9d09fdcf --- /dev/null +++ b/apps/dashboard/src/components/data-table/subscribers/columns.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { formatDate } from "@/lib/formatter"; +import type { RouterOutputs } from "@openstatus/api"; +import type { ColumnDef } from "@tanstack/react-table"; +import { DataTableRowActions } from "./data-table-row-actions"; + +type Subscriber = RouterOutputs["pageSubscriber"]["list"][number]; + +export const columns: ColumnDef[] = [ + { + accessorKey: "email", + header: "Email", + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "createdAt", + header: "Created At", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = row.getValue("createdAt"); + if (value instanceof Date) return formatDate(value); + if (!value) return "-"; + return value; + }, + meta: { + cellClassName: "font-mono", + }, + }, + { + accessorKey: "acceptedAt", + header: "Accepted At", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => { + const value = row.getValue("acceptedAt"); + if (value instanceof Date) return formatDate(value); + if (!value) return "-"; + return value; + }, + meta: { + cellClassName: "font-mono", + }, + }, + { + id: "actions", + cell: ({ row }) => , + meta: { + cellClassName: "w-8", + }, + }, +]; diff --git a/apps/dashboard/src/components/data-table/subscribers/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/subscribers/data-table-row-actions.tsx new file mode 100644 index 000000000..592ab34a8 --- /dev/null +++ b/apps/dashboard/src/components/data-table/subscribers/data-table-row-actions.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { QuickActions } from "@/components/dropdowns/quick-actions"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@openstatus/api"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { Row } from "@tanstack/react-table"; + +type Subscriber = RouterOutputs["pageSubscriber"]["list"][number]; + +interface DataTableRowActionsProps { + row: Row; +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const trpc = useTRPC(); + const { refetch } = useQuery( + trpc.pageSubscriber.list.queryOptions({ + pageId: row.original.pageId, + }), + ); + + const deleteAction = useMutation( + trpc.pageSubscriber.delete.mutationOptions({ + onSuccess: () => refetch(), + }), + ); + + return ( + { + await deleteAction.mutateAsync({ + id: row.original.id, + pageId: row.original.pageId, + }); + }, + }} + /> + ); +} diff --git a/apps/dashboard/src/components/data-table/table-cell-badge.tsx b/apps/dashboard/src/components/data-table/table-cell-badge.tsx new file mode 100644 index 000000000..f28d7fa93 --- /dev/null +++ b/apps/dashboard/src/components/data-table/table-cell-badge.tsx @@ -0,0 +1,59 @@ +import { cn } from "@/lib/utils"; +import { useEffect, useRef, useState } from "react"; +import { Badge } from "../ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; + +export function TableCellBadge({ + value, + className, + ...props +}: React.ComponentProps & { value: unknown }) { + const ref = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + const [open, setOpen] = useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + if (ref.current) { + setIsTruncated(ref.current.scrollWidth > ref.current.clientWidth); + } + }, [ref]); + + return ( + + + {isTruncated ? ( + + event.preventDefault()} + onPointerDown={(event) => event.preventDefault()} + asChild + > + + {String(value)} + + + {String(value)} + + ) : ( + + {String(value)} + + )} + + + ); +} diff --git a/apps/dashboard/src/components/data-table/table-cell-boolean.tsx b/apps/dashboard/src/components/data-table/table-cell-boolean.tsx new file mode 100644 index 000000000..97b728665 --- /dev/null +++ b/apps/dashboard/src/components/data-table/table-cell-boolean.tsx @@ -0,0 +1,22 @@ +import { cn } from "@/lib/utils"; + +export function TableCellBoolean({ + value, + className, + ...props +}: React.ComponentProps<"div"> & { value: unknown }) { + const _value = Boolean(value); + + return ( +
+ {String(_value)} +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/table-cell-date.tsx b/apps/dashboard/src/components/data-table/table-cell-date.tsx new file mode 100644 index 000000000..e53914206 --- /dev/null +++ b/apps/dashboard/src/components/data-table/table-cell-date.tsx @@ -0,0 +1,29 @@ +import { cn } from "@/lib/utils"; +import { format } from "date-fns"; + +export function TableCellDate({ + value, + className, + formatStr = "LLL dd, y HH:mm:ss", + ...props +}: React.ComponentProps<"div"> & { value: unknown; formatStr?: string }) { + if (value instanceof Date) { + return ( +
+ {format(value, formatStr)} +
+ ); + } + if (typeof value === "string") { + return ( +
+ {value} +
+ ); + } + return ( +
+ - +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/table-cell-link.tsx b/apps/dashboard/src/components/data-table/table-cell-link.tsx new file mode 100644 index 000000000..0f3bbc7de --- /dev/null +++ b/apps/dashboard/src/components/data-table/table-cell-link.tsx @@ -0,0 +1,33 @@ +import { Link } from "@/components/common/link"; +import { cn } from "@/lib/utils"; +import { ArrowUpRight, ChevronRight } from "lucide-react"; + +export function TableCellLink({ + value, + className, + ...props +}: React.ComponentProps & { + value: unknown; +}) { + if (typeof value === "string") { + const isExternal = props.href?.toString().startsWith("http"); + const externalProps = isExternal + ? { target: "_blank", rel: "noopener noreferrer" } + : {}; + const Icon = isExternal ? ArrowUpRight : ChevronRight; + return ( + + {value} + + + ); + } + return
-
; +} diff --git a/apps/dashboard/src/components/data-table/table-cell-number.tsx b/apps/dashboard/src/components/data-table/table-cell-number.tsx new file mode 100644 index 000000000..bf1b96138 --- /dev/null +++ b/apps/dashboard/src/components/data-table/table-cell-number.tsx @@ -0,0 +1,20 @@ +import { cn } from "@/lib/utils"; + +export function TableCellNumber({ + value, + className, + unit, + ...props +}: React.ComponentProps<"div"> & { value: unknown; unit?: string }) { + const _value = Number(value); + if (Number.isNaN(_value)) { + return
N/A
; + } + + return ( +
+ {_value} + {unit && {unit}} +
+ ); +} diff --git a/apps/dashboard/src/components/data-table/table-cell-unavailable.tsx b/apps/dashboard/src/components/data-table/table-cell-unavailable.tsx new file mode 100644 index 000000000..7c3cd7c87 --- /dev/null +++ b/apps/dashboard/src/components/data-table/table-cell-unavailable.tsx @@ -0,0 +1,12 @@ +import { cn } from "@/lib/utils"; + +export function TableCellUnavailable({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ N/A +
+ ); +} diff --git a/apps/dashboard/src/components/date-picker.tsx b/apps/dashboard/src/components/date-picker.tsx new file mode 100644 index 000000000..ecbe3fd16 --- /dev/null +++ b/apps/dashboard/src/components/date-picker.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import type { DateRange } from "react-day-picker"; + +import { Kbd } from "@/components/common/kbd"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { formatDateForInput } from "@/lib/formatter"; +import { endOfDay } from "date-fns"; + +type DatePickerProps = { + range: DateRange; + onSelect: (range: DateRange) => void; + presets: { id: string; label: string; values: DateRange; shortcut: string }[]; +}; + +export function DatePicker({ range, onSelect, presets }: DatePickerProps) { + const [today] = useState(new Date()); + const disableBefore = presets[presets.length - 1]?.values?.from; + + return ( +
+
+
+
+
+
+ Presets +
+ {presets.map((preset) => { + const isSelected = + range.from?.getTime() === preset.values.from?.getTime() && + range.to?.getTime() === preset.values.to?.getTime(); + + return ( + + ); + })} +
+
+
+ +
+ { + if (newDate) { + onSelect({ + ...newDate, + to: newDate.to ? endOfDay(newDate.to) : undefined, + }); + } + }} + className="p-2" + disabled={[ + { after: today }, // Dates before today + { before: disableBefore ?? today }, // Dates before last action + ]} + /> +
+
+ +
+

+ Custom Range +

+
+
+ + { + const newDate = new Date(e.target.value); + if (!Number.isNaN(newDate.getTime())) { + onSelect({ ...range, from: newDate }); + } + }} + disabled={!range.from} + /> +
+
+ + { + const newDate = new Date(e.target.value); + if (!Number.isNaN(newDate.getTime())) { + onSelect({ ...range, to: newDate }); + } + }} + disabled={!range.to} + /> +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/development-indicator.tsx b/apps/dashboard/src/components/development-indicator.tsx new file mode 100644 index 000000000..c18acce86 --- /dev/null +++ b/apps/dashboard/src/components/development-indicator.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useIsMobile } from "@/hooks/use-mobile"; +import * as Portal from "@radix-ui/react-portal"; +import { Kbd } from "./common/kbd"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; + +export function DevelopmentIndicator() { + const isMobile = useIsMobile(); + + if (process.env.NODE_ENV !== "production") return null; + + return ( + +
+
+
+ + + +
+ In Beta +
+
+ + {!isMobile ? ( +

+ Press{" "} + + F + {" "} + key to provide feedback. +

+ ) : ( +

Use a larger screen to provide feedback.

+ )} +
+
+
+
+
+ + ); +} diff --git a/apps/dashboard/src/components/dialogs/export-code.tsx b/apps/dashboard/src/components/dialogs/export-code.tsx new file mode 100644 index 000000000..8287d4095 --- /dev/null +++ b/apps/dashboard/src/components/dialogs/export-code.tsx @@ -0,0 +1,83 @@ +import { Link } from "@/components/common/link"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import type { DialogProps } from "@radix-ui/react-dialog"; +import { Check, Copy } from "lucide-react"; + +// TODL: make it dynamic +const YML = `openstatus-marketing: + name: OpenStatus Marketing + description: Marketing website for OpenStatus + active: true + public: false + frequency: "10m" + regions: ["ams", "fra", "gru", "hkg", "iad"] + kind: "http" + request: + url: https://api.openstatus.dev + method: GET`; + +export function ExportCodeDialog(props: DialogProps) { + const { copy, isCopied } = useCopyToClipboard(); + + return ( + + + + Export Configuration + + Export and manage your monitor configuration using Infra as Code. + + + + + YAML + Terraform + + +
+              {YML}
+              
+            
+

+ Use a monitor.openstatus.yml file to configure your + monitors. Read more. +

+
+ +
+              TODO:
+            
+ {/* TODO: only showcase if there are any assertions */} +

+ The Terraform provider does not support assertions yet. +

+

+ Use a Terraform provider to manage your monitors.{" "} + Read more. +

+
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/dialogs/upgrade.tsx b/apps/dashboard/src/components/dialogs/upgrade.tsx new file mode 100644 index 000000000..49ca77acb --- /dev/null +++ b/apps/dashboard/src/components/dialogs/upgrade.tsx @@ -0,0 +1,62 @@ +import { Note, NoteButton } from "@/components/common/note"; +import { DataTable } from "@/components/data-table/billing/data-table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useTRPC } from "@/lib/trpc/client"; +import type { WorkspacePlan } from "@openstatus/db/src/schema"; +import type { DialogProps } from "@radix-ui/react-dialog"; +import { useQuery } from "@tanstack/react-query"; +import { CalendarClock } from "lucide-react"; + +const PLANS = { + free: ["starter", "team"], + starter: ["team"], + team: [], +} satisfies Record; + +export function UpgradeDialog(props: DialogProps) { + const trpc = useTRPC(); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + + if (!workspace) return null; + + const restrictTo = PLANS[workspace.plan]; + + return ( + + + + Upgrade Workspace + + Upgrade your workspace to support more monitors, status pages, + regions, and much more. + + + {restrictTo.length === 0 ? ( + + + You're already on our highest plan. Let's chat about your + needs. + + + Book a call + + + + ) : ( + + )} + + + ); +} diff --git a/apps/dashboard/src/components/domains/domain-configuration.tsx b/apps/dashboard/src/components/domains/domain-configuration.tsx new file mode 100644 index 000000000..385b5d314 --- /dev/null +++ b/apps/dashboard/src/components/domains/domain-configuration.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +import { Note } from "@/components/common/note"; +import { getSubdomain } from "@/lib/domains"; +import { CircleCheck } from "lucide-react"; +import DomainStatusIcon from "./domain-status-icon"; +import { useDomainStatus } from "./use-domain-status"; + +export const InlineSnippet = ({ + className, + children, +}: { + className?: string; + children?: string; +}) => { + return ( + + {children} + + ); +}; + +// FIXME: add loading state! +export default function DomainConfiguration({ domain }: { domain: string }) { + const { status, domainJson } = useDomainStatus(domain); + + if (!status || !domainJson) return null; + + if (status === "Valid Configuration") + return ( + + + Your domain is configured and you can use it to access your status page. + + ); + + const subdomain = + domainJson?.name && domainJson?.apexName + ? getSubdomain(domainJson.name, domainJson.apexName) + : null; + + const txtVerification = + (status === "Pending Verification" && + domainJson?.verification?.find((x) => x.type === "TXT")) || + null; + + return ( +
+
+ +

{status}

+
+ {txtVerification ? ( + <> +

+ Please set the following TXT record on{" "} + {domainJson.apexName} to prove + ownership of {domainJson.name}: +

+
+
+

Type

+

{txtVerification.type}

+
+
+

Name

+

+ {txtVerification.domain.slice( + 0, + txtVerification.domain.length - + (domainJson?.apexName?.length || 0) - + 1, + )} +

+
+
+

Value

+

+ {txtVerification.value} +

+
+
+

+ Warning: if you are using this domain for another site, setting this + TXT record will transfer domain ownership away from that site and + break it. Please exercise caution when setting this record. +

+ + ) : status === "Unknown Error" ? ( +

{domainJson?.error?.message}

+ ) : ( + <> + + + + A Record{!subdomain && " (recommended)"} + + + CNAME Record{subdomain && " (recommended)"} + + + +
+

+ To configure your apex domain ( + {domainJson.apexName} + ), set the following A record on your DNS provider to + continue: +

+
+
+

Type

+

A

+
+
+

Name

+

@

+
+
+

Value

+

76.76.21.21

+
+
+

TTL

+

86400

+
+
+
+
+

Value

+

cname.vercel-dns.com

+
+
+

Type

+

CNAME

+
+
+

Name

+

+ {subdomain ?? "www"} +

+
+
+

Value

+

+ {"cname.vercel-dns.com"} +

+
+
+

TTL

+

86400

+
+
+
+
+
+

+ Note: for TTL, if 86400 is not + available, set the highest value possible. Also, domain propagation + can take up to an hour. +

+ + )} +
+ ); +} diff --git a/apps/dashboard/src/components/domains/domain-status-icon.tsx b/apps/dashboard/src/components/domains/domain-status-icon.tsx new file mode 100644 index 000000000..3de439ca5 --- /dev/null +++ b/apps/dashboard/src/components/domains/domain-status-icon.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { AlertCircle, CheckCircle2, LoaderCircle, XCircle } from "lucide-react"; + +import type { DomainVerificationStatusProps } from "@openstatus/api/src/router/domain"; + +export default function DomainStatusIcon({ + status, + loading, +}: { + status: DomainVerificationStatusProps; + loading?: boolean; +}) { + return loading ? ( + + ) : status === "Valid Configuration" ? ( + + ) : status === "Pending Verification" ? ( + + ) : ( + + ); +} diff --git a/apps/dashboard/src/components/domains/use-domain-status.ts b/apps/dashboard/src/components/domains/use-domain-status.ts new file mode 100644 index 000000000..a5dc9a741 --- /dev/null +++ b/apps/dashboard/src/components/domains/use-domain-status.ts @@ -0,0 +1,59 @@ +import type { DomainVerificationStatusProps } from "@openstatus/api/src/router/domain"; + +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; + +export function useDomainStatus(domain: string) { + const trpc = useTRPC(); + const { data: domainJson, refetch: refetchDomain } = useQuery( + trpc.domain.getDomainResponse.queryOptions({ domain }), + ); + const { data: configJson, refetch: refetchConfig } = useQuery( + trpc.domain.getConfigResponse.queryOptions({ domain }), + ); + const { data: verificationJson, refetch: refetchVerification } = useQuery( + trpc.domain.verifyDomain.queryOptions( + { domain }, + { enabled: !domainJson?.verified }, + ), + ); + + // NOTE: refetch every 5 seconds to check for the status + useEffect(() => { + const interval = setInterval(() => { + refetchDomain(); + refetchConfig(); + refetchVerification(); + }, 5000); + return () => clearInterval(interval); + }, [refetchDomain, refetchConfig, refetchVerification]); + + let status: DomainVerificationStatusProps = "Valid Configuration"; + + if (domainJson?.error?.code === "not_found") { + // domain not found on Vercel project + status = "Domain Not Found"; + + // unknown error + } else if (domainJson?.error) { + status = "Unknown Error"; + + // if domain is not verified, we try to verify now + } else if (!domainJson?.verified) { + status = "Pending Verification"; + + // domain was just verified + if (verificationJson?.verified) { + status = "Valid Configuration"; + } + } else if (configJson?.misconfigured) { + status = "Invalid Configuration"; + } else { + status = "Valid Configuration"; + } + return { + status, + domainJson, + }; +} diff --git a/apps/dashboard/src/components/dropdowns/quick-actions.tsx b/apps/dashboard/src/components/dropdowns/quick-actions.tsx new file mode 100644 index 000000000..691e94334 --- /dev/null +++ b/apps/dashboard/src/components/dropdowns/quick-actions.tsx @@ -0,0 +1,193 @@ +"use client"; + +import type * as React from "react"; +import { useState, useTransition } from "react"; + +import { type LucideIcon, MoreHorizontal, Trash2 } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import type { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu"; +import { isTRPCClientError } from "@trpc/client"; +import { toast } from "sonner"; + +interface QuickActionsProps extends React.ComponentProps { + align?: DropdownMenuContentProps["align"]; + side?: DropdownMenuContentProps["side"]; + actions?: { + id: string; + label: string; + icon: LucideIcon; + variant: "default" | "destructive"; + onClick?: () => Promise | void; + }[]; + deleteAction?: { + title: string; + /** + * If set, an input field will require the user input to validate deletion + */ + confirmationValue?: string; + submitAction?: () => Promise; + }; +} + +export function QuickActions({ + align = "end", + side, + className, + actions, + deleteAction, + children, + ...props +}: QuickActionsProps) { + const [value, setValue] = useState(""); + const [isPending, startTransition] = useTransition(); + const [open, setOpen] = useState(false); + + const handleDelete = async () => { + startTransition(async () => { + if (!deleteAction?.submitAction) return; + const promise = deleteAction.submitAction(); + toast.promise(promise, { + loading: "Deleting...", + success: "Deleted", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "Failed to delete"; + }, + }); + try { + await promise; + } catch (error) { + console.error("Failed to delete:", error); + } finally { + setOpen(false); + } + }); + }; + + return ( + + + + {children ?? ( + + )} + + + + Quick Actions + + {actions + ?.filter((item) => item.id !== "delete") + .map((item) => ( + + { + e.stopPropagation(); + item.onClick?.(); + }} + > + + {item.label} + + + ))} + {deleteAction && ( + <> + {/* NOTE: add a separator only if actions exist */} + {actions?.length ? : null} + + + + Delete + + + + )} + + + { + // NOTE: bug where the body is not clickable after closing the alert dialog + event.preventDefault(); + document.body.style.pointerEvents = ""; + }} + > + + + Are you sure about deleting `{deleteAction?.title}`? + + + This action cannot be undone. This will permanently remove the entry + from the database. + + + {deleteAction?.confirmationValue ? ( +
+

+ Please write ' + + {deleteAction?.confirmationValue} + + ' to confirm +

+ setValue(e.target.value)} /> +
+ ) : null} + + e.stopPropagation()}> + Cancel + + { + e.preventDefault(); + handleDelete(); + }} + > + {isPending ? "Deleting..." : "Delete"} + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/forms/form-alert-dialog.tsx b/apps/dashboard/src/components/forms/form-alert-dialog.tsx new file mode 100644 index 000000000..d8cc5e3ca --- /dev/null +++ b/apps/dashboard/src/components/forms/form-alert-dialog.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { isTRPCClientError } from "@trpc/client"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +interface FormAlertDialogProps { + title: string; + confirmationValue: string; + submitAction: () => Promise; + children?: React.ReactNode; +} + +export function FormAlertDialog({ + title, + confirmationValue, + submitAction, + children, +}: FormAlertDialogProps) { + const [value, setValue] = useState(""); + const [isPending, startTransition] = useTransition(); + const [open, setOpen] = useState(false); + + const handleDelete = async () => { + try { + startTransition(async () => { + const promise = submitAction(); + toast.promise(promise, { + loading: "Deleting...", + success: "Deleted", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "Failed to delete"; + }, + }); + await promise; + setOpen(false); + }); + } catch (error) { + console.error("Failed to revoke:", error); + } + }; + + return ( + + + {children ?? ( + + )} + + + + + Are you sure about revoking `{title}`? + + + This action cannot be undone. This will permanently revoke the API + key. + + +
+

+ Please write ' + {confirmationValue} + ' to confirm +

+ setValue(e.target.value)} /> +
+ + Cancel + { + e.preventDefault(); + handleDelete(); + }} + > + {isPending ? "Deleting..." : "Delete"} + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/forms/form-card.tsx b/apps/dashboard/src/components/forms/form-card.tsx new file mode 100644 index 000000000..1baf5130e --- /dev/null +++ b/apps/dashboard/src/components/forms/form-card.tsx @@ -0,0 +1,195 @@ +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +import { type VariantProps, cva } from "class-variance-authority"; + +// py-0 +const formCardVariants = cva( + "group relative w-full overflow-hidden py-0 shadow-none gap-4", + { + variants: { + variant: { + default: "", + destructive: "border-destructive", + }, + defaultVariants: { + variant: "default", + }, + }, + }, +); + +// NOTE: Add a formcardprovider to share the variant prop + +export function FormCard({ + children, + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( + + {children} + + ); +} + +export function FormCardHeader({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + {children} + + ); +} + +export function FormCardTitle({ children }: { children: React.ReactNode }) { + return {children}; +} + +export function FormCardDescription({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} + +export function FormCardContent({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( + + {children} + + ); +} + +export function FormCardSeparator({ + ...props +}: React.ComponentProps) { + return ; +} + +const formCardFooterVariants = cva( + "border-t flex items-center gap-2 pb-4 px-4 [&>:last-child]:ml-auto [.border-t]:pt-4", + { + variants: { + variant: { + default: "", + destructive: "border-destructive bg-destructive/5", + }, + defaultVariants: { + variant: "default", + }, + }, + }, +); + +export function FormCardFooter({ + children, + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( + + {children} + + ); +} + +export function FormCardFooterInfo({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function FormCardGroup({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function FormCardUpgrade({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function FormCardEmpty({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/dashboard/src/components/forms/form-sheet.tsx b/apps/dashboard/src/components/forms/form-sheet.tsx new file mode 100644 index 000000000..7514f5ea9 --- /dev/null +++ b/apps/dashboard/src/components/forms/form-sheet.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; + +export function FormSheetContent({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} + +export function FormSheetHeader({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} + +export function FormSheetFooter({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} + +export function FormSheetFooterInfo({ + children, + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ {children} +

+ ); +} + +export function FormSheetTrigger({ + children, + className, + disabled, + ...props +}: React.ComponentProps) { + return ( + + {children} + + ); +} + +export { + SheetTitle as FormSheetTitle, + SheetDescription as FormSheetDescription, + Sheet as FormSheet, +}; diff --git a/apps/dashboard/src/components/forms/maintenance/form.tsx b/apps/dashboard/src/components/forms/maintenance/form.tsx new file mode 100644 index 000000000..4e1677811 --- /dev/null +++ b/apps/dashboard/src/components/forms/maintenance/form.tsx @@ -0,0 +1,377 @@ +"use client"; + +import { + EmptyStateContainer, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { + FormCardContent, + FormCardSeparator, +} from "@/components/forms/form-card"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { TabsContent } from "@/components/ui/tabs"; +import { TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { isTRPCClientError } from "@trpc/client"; +import { format } from "date-fns"; +import { CalendarIcon, ClockIcon } from "lucide-react"; +import type React from "react"; +import { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const schema = z.object({ + title: z.string(), + message: z.string(), + startDate: z.date(), + endDate: z.date(), + monitors: z.array(z.number()), +}); + +export type FormValues = z.infer; + +export function FormMaintenance({ + defaultValues, + onSubmit, + className, + monitors, + ...props +}: Omit, "onSubmit"> & { + defaultValues?: FormValues; + monitors: { id: number; name: string; url: string }[]; + onSubmit: (values: FormValues) => Promise; +}) { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: defaultValues ?? { + title: "", + message: "", + startDate: new Date(), + endDate: new Date(), + monitors: [], + }, + }); + const watchMessage = form.watch("message"); + const [isPending, startTransition] = useTransition(); + + function submitAction(values: FormValues) { + if (isPending) return; + + startTransition(async () => { + try { + const promise = onSubmit(values); + toast.promise(promise, { + loading: "Saving...", + success: () => "Saved", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "Failed to save"; + }, + }); + await promise; + } catch (error) { + console.error(error); + } + }); + } + + return ( +
+ + + ( + + Title + + + + + + )} + /> + + + + {/* TODO: */} + ( + + Start Date + + + + + + + + +
+
+ +
+ { + try { + const date = field.value + ?.toISOString() + .split("T")[0]; + + field.onChange( + new Date(`${date}T${e.target.value}`), + ); + } catch (error) { + console.error(error); + } + }} + /> +
+
+
+
+
+
+
+ When the maintenance starts. + +
+ )} + /> +
+ + + ( + + End Date + + + + + + + + +
+
+ +
+ { + try { + const date = field.value + ?.toISOString() + .split("T")[0]; + + field.onChange( + new Date(`${date}T${e.target.value}`), + ); + } catch (error) { + console.error(error); + } + }} + /> +
+
+
+
+
+
+
+ When the maintenance ends + +
+ )} + /> +
+ + + + + Writing + Preview + + + ( + + Message + +
+ + + + + + + View Documentation + + +