Openstatus www.openstatus.dev

chore: delete web dashboard (#1416)

* chore: delete web dashboard

* chore: delete ping

authored by

Maximilian Kaske and committed by
GitHub
c40fa7fc d14576cb

-4084
-11
apps/web/src/app/(pages)/app/(auth)/login/_components/actions.ts
··· 1 - "use server"; 2 - 3 - import { signIn } from "@/lib/auth"; 4 - 5 - export async function signInWithResendAction(formData: FormData) { 6 - try { 7 - await signIn("resend", formData); 8 - } catch (_e) { 9 - // console.error(e); 10 - } 11 - }
-56
apps/web/src/app/(pages)/app/(auth)/login/layout.tsx
··· 1 - import Image from "next/image"; 2 - import Link from "next/link"; 3 - import { redirect } from "next/navigation"; 4 - 5 - import { auth } from "@/lib/auth"; 6 - 7 - export default async function AuthLayout({ 8 - children, 9 - }: { 10 - children: React.ReactNode; 11 - }) { 12 - const session = await auth(); 13 - if (session) redirect("/app"); 14 - 15 - return ( 16 - <div className="grid min-h-screen grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5"> 17 - <aside className="col-span-1 flex w-full flex-col gap-4 border border-border p-4 backdrop-blur-[2px] md:p-8 xl:col-span-2"> 18 - <Link href="/" className="relative h-8 w-8"> 19 - <Image 20 - src="/icon.png" 21 - alt="OpenStatus" 22 - height={32} 23 - width={32} 24 - className="rounded-full border border-border" 25 - /> 26 - </Link> 27 - <div className="mx-auto flex w-full max-w-lg flex-1 flex-col justify-center gap-8 text-center md:text-left"> 28 - <div className="mx-auto grid gap-3"> 29 - <h1 className="font-cal text-3xl text-foreground"> 30 - Open Source Monitoring Service 31 - </h1> 32 - <p className="text-muted-foreground text-sm"> 33 - Monitor your website or API and create your own status page within 34 - a couple of minutes. Want to know how it works? <br /> 35 - <br /> 36 - Check out{" "} 37 - <a 38 - href="https://github.com/openstatushq/openstatus" 39 - target="_blank" 40 - rel="noreferrer" 41 - className="text-foreground underline underline-offset-4 hover:no-underline" 42 - > 43 - GitHub 44 - </a>{" "} 45 - and let us know your use case! 46 - </p> 47 - </div> 48 - </div> 49 - <div className="md:h-8" /> 50 - </aside> 51 - <main className="container col-span-1 mx-auto flex items-center justify-center md:col-span-1 xl:col-span-3"> 52 - {children} 53 - </main> 54 - </div> 55 - ); 56 - }
-77
apps/web/src/app/(pages)/app/(auth)/login/page.tsx
··· 1 - import Link from "next/link"; 2 - 3 - import { Button, Separator } from "@openstatus/ui"; 4 - 5 - import { Shell } from "@/components/dashboard/shell"; 6 - import DevModeContainer from "@/components/dev-mode-container"; 7 - import { Icons } from "@/components/icons"; 8 - import { signIn } from "@/lib/auth"; 9 - import MagicLinkForm from "./_components/magic-link-form"; 10 - import { searchParamsCache } from "./search-params"; 11 - 12 - const isDev = process.env.NODE_ENV === "development"; 13 - 14 - export default async function Page(props: { 15 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 16 - }) { 17 - const searchParams = await props.searchParams; 18 - const { redirectTo } = searchParamsCache.parse(searchParams); 19 - 20 - return ( 21 - <Shell className="my-4 grid w-full max-w-xl gap-6 md:p-10"> 22 - <div className="flex flex-col gap-2 text-center"> 23 - <h1 className="font-semibold text-3xl tracking-tight">Sign In</h1> 24 - <p className="text-muted-foreground text-sm"> 25 - Get started now. No credit card required. 26 - </p> 27 - </div> 28 - <div className="grid gap-3"> 29 - {isDev ? ( 30 - <DevModeContainer className="grid gap-3"> 31 - <MagicLinkForm /> 32 - <Separator /> 33 - </DevModeContainer> 34 - ) : null} 35 - <form 36 - action={async () => { 37 - "use server"; 38 - await signIn("github", { redirectTo }); 39 - }} 40 - className="w-full" 41 - > 42 - <Button type="submit" className="w-full"> 43 - Sign in with GitHub <Icons.github className="ml-2 h-4 w-4" /> 44 - </Button> 45 - </form> 46 - <form 47 - action={async () => { 48 - "use server"; 49 - await signIn("google", { redirectTo }); 50 - }} 51 - className="w-full" 52 - > 53 - <Button type="submit" className="w-full" variant="outline"> 54 - Sign in with Google <Icons.google className="ml-2 h-4 w-4" /> 55 - </Button> 56 - </form> 57 - </div> 58 - <p className="px-8 text-center text-muted-foreground text-sm"> 59 - By clicking continue, you agree to our{" "} 60 - <Link 61 - href="/legal/terms" 62 - className="underline underline-offset-4 hover:text-primary hover:no-underline" 63 - > 64 - Terms of Service 65 - </Link>{" "} 66 - and{" "} 67 - <Link 68 - href="/legal/privacy" 69 - className="underline underline-offset-4 hover:text-primary hover:no-underline" 70 - > 71 - Privacy Policy 72 - </Link> 73 - . 74 - </p> 75 - </Shell> 76 - ); 77 - }
-7
apps/web/src/app/(pages)/app/(auth)/login/search-params.ts
··· 1 - import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 - 3 - export const searchParamsParsers = { 4 - redirectTo: parseAsString.withDefault("/app"), 5 - }; 6 - 7 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-5
apps/web/src/app/(pages)/app/(auth)/sign-in/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - export default function Page() { 4 - return redirect("/app/login"); 5 - }
-5
apps/web/src/app/(pages)/app/(auth)/sign-up/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - export default function Page() { 4 - return redirect("/app/login"); 5 - }
-17
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/incidents/(overview)/layout.tsx
··· 1 - import type { ReactNode } from "react"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import AppPageLayout from "@/components/layout/app-page-layout"; 5 - 6 - export default async function Layout({ children }: { children: ReactNode }) { 7 - return ( 8 - <AppPageLayout> 9 - <Header 10 - title="Incidents" 11 - description="Overview of all your incidents." 12 - actions={undefined} 13 - /> 14 - {children} 15 - </AppPageLayout> 16 - ); 17 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/incidents/(overview)/loading.tsx
··· 1 - import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 2 - 3 - export default function Loading() { 4 - return <DataTableSkeleton />; 5 - }
-32
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/incidents/(overview)/page.tsx
··· 1 - import { EmptyState } from "@/components/dashboard/empty-state"; 2 - import { columns } from "@/components/data-table/incident/columns"; 3 - import { DataTable } from "@/components/data-table/incident/data-table"; 4 - import { api } from "@/trpc/server"; 5 - import { ReportInfoBanner } from "./report-info-banner"; 6 - 7 - export default async function IncidentPage() { 8 - const [incidents, statusReports] = await Promise.all([ 9 - api.incident.getIncidentsByWorkspace.query(), 10 - api.statusReport.getStatusReportByWorkspace.query(), 11 - ]); 12 - 13 - if (incidents?.length === 0) 14 - return ( 15 - <> 16 - <EmptyState 17 - icon="siren" 18 - title="No incidents" 19 - description="Hopefully you will see this screen for a long time." 20 - action={undefined} 21 - /> 22 - {!statusReports.length ? <ReportInfoBanner /> : null} 23 - </> 24 - ); 25 - 26 - return ( 27 - <> 28 - <DataTable columns={columns} data={incidents} /> 29 - {!statusReports.length ? <ReportInfoBanner /> : null} 30 - </> 31 - ); 32 - }
-24
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/incidents/(overview)/report-info-banner.tsx
··· 1 - import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 2 - import { Megaphone } from "lucide-react"; 3 - import Link from "next/link"; 4 - 5 - export function ReportInfoBanner() { 6 - return ( 7 - <Alert className="max-w-4xl"> 8 - <Megaphone className="h-4 w-4" /> 9 - <AlertTitle>Looking for Status Reports?</AlertTitle> 10 - <AlertDescription> 11 - The incidents will be created automatically if an endpoint fails. If you 12 - want to inform your users about a planned maintenance, or a current 13 - issue you can create a status report. Go to{" "} 14 - <Link 15 - className="underline underline-offset-4 hover:text-primary hover:no-underline" 16 - href="./status-pages" 17 - > 18 - Status Pages 19 - </Link>{" "} 20 - and select a page you want to report on. 21 - </AlertDescription> 22 - </Alert> 23 - ); 24 - }
-36
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/incidents/[id]/layout.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import AppPageWithSidebarLayout from "@/components/layout/app-page-with-sidebar-layout"; 5 - import { api } from "@/trpc/server"; 6 - 7 - export default async function Layout(props: { 8 - children: React.ReactNode; 9 - params: Promise<{ workspaceSlug: string; id: string }>; 10 - }) { 11 - const params = await props.params; 12 - 13 - const { children } = props; 14 - 15 - const id = params.id; 16 - 17 - const [incidents, incident] = await Promise.all([ 18 - api.incident.getIncidentsByWorkspace.query(), 19 - api.incident.getIncidentById.query({ 20 - id: Number(id), 21 - }), 22 - ]); 23 - 24 - if (!incident) { 25 - return notFound(); 26 - } 27 - 28 - const incidentIndex = incidents.findIndex((item) => item.id === incident.id); 29 - 30 - return ( 31 - <AppPageWithSidebarLayout id="incidents"> 32 - <Header title={`Incident #${incidentIndex + 1}`} /> 33 - {children} 34 - </AppPageWithSidebarLayout> 35 - ); 36 - }
-37
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/incidents/[id]/overview/_components/event.tsx
··· 1 - import { format } from "date-fns"; 2 - 3 - import type { ValidIcon } from "@/components/icons"; 4 - import { Icons } from "@/components/icons"; 5 - 6 - export function Event({ 7 - label, 8 - date, 9 - icon, 10 - children, 11 - }: { 12 - label: string; 13 - date: Date; 14 - icon: ValidIcon; 15 - children?: React.ReactNode; 16 - }) { 17 - const Icon = Icons[icon]; 18 - return ( 19 - <div className="group -m-2 relative flex gap-4 border border-transparent p-2"> 20 - <div className="relative"> 21 - <div className="rounded-full border bg-background p-2"> 22 - <Icon className="h-4 w-4" /> 23 - </div> 24 - <div className="absolute inset-x-0 mx-auto h-full w-[2px] bg-muted" /> 25 - </div> 26 - <div className="mt-1 flex flex-1 flex-col gap-1"> 27 - <div className="flex items-center justify-between gap-4"> 28 - <p className="font-semibold text-sm">{label}</p> 29 - <p className="mt-px text-right text-[10px] text-muted-foreground"> 30 - <code>{format(new Date(date), "LLL dd, y HH:mm:ss")}</code> 31 - </p> 32 - </div> 33 - {children} 34 - </div> 35 - </div> 36 - ); 37 - }
-113
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/incidents/[id]/overview/page.tsx
··· 1 - import { formatDistanceStrict } from "date-fns"; 2 - import { ArrowUpRight } from "lucide-react"; 3 - import Image from "next/image"; 4 - import Link from "next/link"; 5 - 6 - import { api } from "@/trpc/server"; 7 - import { Event } from "./_components/event"; 8 - 9 - /** 10 - * MetricCards (Like: Duration, Monitor Name, Autoresolved,...) 11 - * 12 - * Start Date + (can we include the response details?) 13 - * Screenshot 14 - * Acknowledged 15 - * Resolved 16 - * 17 - */ 18 - 19 - export default async function IncidentPage(props: { 20 - params: Promise<{ workspaceSlug: string; id: string }>; 21 - }) { 22 - const params = await props.params; 23 - const incident = await api.incident.getIncidentById.query({ 24 - id: Number(params.id), 25 - }); 26 - 27 - const duration = formatDistanceStrict( 28 - new Date(incident.startedAt), 29 - incident?.resolvedAt ? new Date(incident.resolvedAt) : new Date(), 30 - ); 31 - 32 - return ( 33 - <div className="grid gap-6"> 34 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 md:gap-6"> 35 - <div className="flex flex-col gap-2 rounded-lg border px-3 py-2"> 36 - <p className="font-light text-muted-foreground text-sm uppercase"> 37 - Monitor 38 - </p> 39 - <div className="flex flex-row items-end gap-2"> 40 - <p className="font-semibold text-xl">{incident.monitorName}</p> 41 - <Link 42 - href={`/app/${params.workspaceSlug}/monitors/${incident.monitorId}/overview`} 43 - className="text-muted-foreground hover:text-foreground" 44 - > 45 - <ArrowUpRight /> 46 - </Link> 47 - </div> 48 - </div> 49 - <div className="flex flex-col gap-2 rounded-lg border px-3 py-2"> 50 - <p className="font-light text-muted-foreground text-sm uppercase"> 51 - Duration 52 - </p> 53 - <p className="font-semibold text-xl">{duration}</p> 54 - </div> 55 - <div className="flex flex-col gap-2 rounded-lg border px-3 py-2"> 56 - <p className="font-light text-muted-foreground text-sm uppercase"> 57 - Auto-resolved 58 - </p> 59 - <p className="font-mono font-semibold text-xl"> 60 - {incident.autoResolved ? "true" : "false"} 61 - </p> 62 - </div> 63 - </div> 64 - <div className="max-w-xl"> 65 - <Event label="Started" icon="alert-triangle" date={incident.startedAt}> 66 - {incident.incidentScreenshotUrl ? ( 67 - <div className="relative h-64 w-full overflow-hidden rounded-xl border border-border bg-background"> 68 - <a 69 - href={incident.incidentScreenshotUrl} 70 - target="_blank" 71 - rel="noreferrer" 72 - > 73 - <Image 74 - src={incident.incidentScreenshotUrl} 75 - fill={true} 76 - alt="incident screenshot" 77 - className="object-contain" 78 - /> 79 - </a> 80 - </div> 81 - ) : null} 82 - </Event> 83 - {incident?.acknowledgedAt ? ( 84 - <Event 85 - label="Acknowledged" 86 - icon="eye" 87 - date={incident.acknowledgedAt} 88 - /> 89 - ) : null} 90 - {incident?.resolvedAt ? ( 91 - <Event label="Resolved" icon="check" date={incident.resolvedAt}> 92 - {incident.recoveryScreenshotUrl ? ( 93 - <div className="relative h-64 w-full overflow-hidden rounded-xl border border-border bg-background"> 94 - <a 95 - href={incident.recoveryScreenshotUrl} 96 - target="_blank" 97 - rel="noreferrer" 98 - > 99 - <Image 100 - src={incident.recoveryScreenshotUrl} 101 - fill={true} 102 - alt="recovery screenshot" 103 - className="object-contain" 104 - /> 105 - </a> 106 - </div> 107 - ) : null} 108 - </Event> 109 - ) : null} 110 - </div> 111 - </div> 112 - ); 113 - }
-8
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/incidents/[id]/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - export default async function Page(props: { 4 - params: Promise<{ workspaceSlug: string; id: string }>; 5 - }) { 6 - const params = await props.params; 7 - return redirect(`./${params.id}/overview`); 8 - }
-33
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/layout.tsx
··· 1 - import { AppHeader } from "@/components/layout/header/app-header"; 2 - import { api } from "@/trpc/server"; 3 - import { notFound } from "next/navigation"; 4 - import type { ReactNode } from "react"; 5 - import { WorkspaceClientCookie } from "../worskpace-client-cookie"; 6 - 7 - // TODO: make the container min-h-screen and the footer below! 8 - export default async function AppLayout(props: { 9 - children: ReactNode; 10 - params: Promise<{ workspaceSlug: string }>; 11 - }) { 12 - const params = await props.params; 13 - 14 - const { children } = props; 15 - 16 - const { workspaceSlug } = params; 17 - const workspaces = await api.workspace.getUserWorkspaces.query(); 18 - 19 - if (workspaces.length === 0) return notFound(); 20 - if (workspaces.find((w) => w.slug === workspaceSlug) === undefined) 21 - return notFound(); 22 - 23 - // TODO: create a WorkspaceContext to store the `Workspace` object including the `slug` and `plan.limits` 24 - return ( 25 - <div className="container relative mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-6 p-4"> 26 - <AppHeader /> 27 - <main className="z-10 flex w-full flex-1 flex-col items-start justify-center"> 28 - {children} 29 - </main> 30 - <WorkspaceClientCookie {...{ workspaceSlug }} /> 31 - </div> 32 - ); 33 - }
-34
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/(overview)/layout.tsx
··· 1 - import { ButtonWithDisableTooltip } from "@openstatus/ui"; 2 - import Link from "next/link"; 3 - import type { ReactNode } from "react"; 4 - 5 - import { Header } from "@/components/dashboard/header"; 6 - import AppPageLayout from "@/components/layout/app-page-layout"; 7 - import { api } from "@/trpc/server"; 8 - 9 - export const revalidate = 0; // revalidate the data at most every hour 10 - export const dynamic = "force-dynamic"; 11 - export const fetchCache = "force-no-store"; 12 - 13 - export default async function Layout({ children }: { children: ReactNode }) { 14 - const isLimitReached = await api.monitor.isMonitorLimitReached.query(); 15 - 16 - return ( 17 - <AppPageLayout> 18 - <Header 19 - title="Monitors" 20 - description="Overview of all your monitors." 21 - actions={ 22 - <ButtonWithDisableTooltip 23 - tooltip="You reached the limits" 24 - asChild={!isLimitReached} 25 - disabled={isLimitReached} 26 - > 27 - <Link href="./monitors/new">Create</Link> 28 - </ButtonWithDisableTooltip> 29 - } 30 - /> 31 - {children} 32 - </AppPageLayout> 33 - ); 34 - }
-11
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/(overview)/loading.tsx
··· 1 - import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 2 - import { DataTableToolbarSkeleton } from "@/components/data-table/data-table-toolbar-skeleton"; 3 - 4 - export default function Loading() { 5 - return ( 6 - <div className="space-y-4"> 7 - <DataTableToolbarSkeleton /> 8 - <DataTableSkeleton /> 9 - </div> 10 - ); 11 - }
-101
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/(overview)/page.tsx
··· 1 - import Link from "next/link"; 2 - 3 - import { Button } from "@openstatus/ui/src/components/button"; 4 - 5 - import { EmptyState } from "@/components/dashboard/empty-state"; 6 - import { Limit } from "@/components/dashboard/limit"; 7 - import { columns } from "@/components/data-table/monitor/columns"; 8 - import { DataTable } from "@/components/data-table/monitor/data-table"; 9 - import { prepareMetricsByPeriod, prepareStatusByPeriod } from "@/lib/tb"; 10 - import { api } from "@/trpc/server"; 11 - import { searchParamsCache } from "./search-params"; 12 - 13 - export default async function MonitorPage(props: { 14 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 15 - }) { 16 - const searchParams = await props.searchParams; 17 - const search = searchParamsCache.parse(searchParams); 18 - 19 - const monitors = await api.monitor.getMonitorsByWorkspace.query(); 20 - if (monitors?.length === 0) 21 - return ( 22 - <EmptyState 23 - icon="activity" 24 - title="No monitors" 25 - description="Create your first monitor" 26 - action={ 27 - <Button asChild> 28 - <Link href="./monitors/new">Create</Link> 29 - </Button> 30 - } 31 - /> 32 - ); 33 - 34 - const [_incidents, tags, _maintenances, isLimitReached] = await Promise.all([ 35 - api.incident.getIncidentsByWorkspace.query(), 36 - api.monitorTag.getMonitorTagsByWorkspace.query(), 37 - api.maintenance.getLast7DaysByWorkspace.query(), 38 - api.monitor.isMonitorLimitReached.query(), 39 - ]); 40 - 41 - // maybe not very efficient? 42 - // use Suspense and Client call instead? 43 - const monitorsWithData = await Promise.all( 44 - monitors.map(async (monitor) => { 45 - const type = monitor.jobType as "http" | "tcp"; 46 - const [metrics, data] = await Promise.all([ 47 - prepareMetricsByPeriod("1d", type).getData({ 48 - monitorId: String(monitor.id), 49 - }), 50 - prepareStatusByPeriod("7d", type).getData({ 51 - monitorId: String(monitor.id), 52 - }), 53 - ]); 54 - 55 - const [current] = metrics.data?.sort((a, b) => 56 - (a.lastTimestamp || 0) - (b.lastTimestamp || 0) < 0 ? 1 : -1, 57 - ) || [undefined]; 58 - 59 - const incidents = _incidents.filter( 60 - (incident) => incident.monitorId === monitor.id, 61 - ); 62 - 63 - const tags = monitor.monitorTagsToMonitors.map( 64 - ({ monitorTag }) => monitorTag, 65 - ); 66 - 67 - const maintenances = _maintenances.filter((maintenance) => 68 - maintenance.monitors.includes(monitor.id), 69 - ); 70 - 71 - return { 72 - monitor, 73 - metrics: current, 74 - data: data.data, 75 - incidents, 76 - maintenances, 77 - tags, 78 - isLimitReached, 79 - }; 80 - }), 81 - ); 82 - 83 - return ( 84 - <> 85 - <DataTable 86 - defaultColumnFilters={[ 87 - { id: "tags", value: search.tags }, 88 - { id: "public", value: search.public }, 89 - ].filter((v) => v.value !== null)} 90 - columns={columns} 91 - data={monitorsWithData} 92 - tags={tags} 93 - defaultPagination={{ 94 - pageIndex: search.pageIndex, 95 - pageSize: search.pageSize, 96 - }} 97 - /> 98 - {isLimitReached ? <Limit /> : null} 99 - </> 100 - ); 101 - }
-16
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/(overview)/search-params.ts
··· 1 - import { 2 - createSearchParamsCache, 3 - parseAsArrayOf, 4 - parseAsBoolean, 5 - parseAsInteger, 6 - parseAsString, 7 - } from "nuqs/server"; 8 - 9 - export const searchParamsParsers = { 10 - tags: parseAsArrayOf(parseAsString), 11 - public: parseAsArrayOf(parseAsBoolean), 12 - pageSize: parseAsInteger.withDefault(10), 13 - pageIndex: parseAsInteger.withDefault(0), 14 - }; 15 - 16 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-106
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/data-table-wrapper.tsx
··· 1 - // HACK: because the `getRowCanExpand` `renderSubComponent` functions 2 - // have to be in a Client Component 3 - 4 - "use client"; 5 - 6 - import type { 7 - ColumnFiltersState, 8 - PaginationState, 9 - Row, 10 - } from "@tanstack/react-table"; 11 - 12 - import * as assertions from "@openstatus/assertions"; 13 - 14 - import { CopyToClipboardButton } from "@/components/dashboard/copy-to-clipboard-button"; 15 - import { columns } from "@/components/data-table/columns"; 16 - import { DataTable } from "@/components/data-table/data-table"; 17 - import { LoadingAnimation } from "@/components/loading-animation"; 18 - import { ResponseDetailTabs } from "@/components/ping-response-analysis/response-detail-tabs"; 19 - import type { Trigger } from "@/lib/monitor/utils"; 20 - import { api } from "@/trpc/rq-client"; 21 - import type { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; 22 - import type { z } from "zod"; 23 - 24 - // FIXME: use proper type 25 - export type Monitor = { 26 - type: "http" | "tcp"; 27 - monitorId: string; 28 - latency: number; 29 - region: z.infer<typeof monitorRegionSchema>; 30 - statusCode?: number | null; 31 - timestamp: number; 32 - workspaceId: string; 33 - cronTimestamp: number | null; 34 - error: boolean; 35 - trigger: Trigger | null; 36 - }; 37 - 38 - export function DataTableWrapper({ 39 - data, 40 - filters, 41 - pagination, 42 - }: { 43 - data: Monitor[]; 44 - filters?: ColumnFiltersState; 45 - pagination?: PaginationState; 46 - }) { 47 - return ( 48 - <DataTable 49 - columns={columns} 50 - data={data} 51 - // REMINDER: we currently only support HTTP monitors with more details 52 - getRowCanExpand={(row) => row.original.type === "http"} 53 - renderSubComponent={renderSubComponent} 54 - defaultColumnFilters={filters} 55 - defaultPagination={pagination} 56 - defaultVisibility={ 57 - data.length && data[0].type === "tcp" ? { statusCode: false } : {} 58 - } 59 - /> 60 - ); 61 - } 62 - 63 - function renderSubComponent({ row }: { row: Row<Monitor> }) { 64 - return <Details row={row} />; 65 - } 66 - 67 - // REMINDER: only HTTP monitors have more details 68 - function Details({ row }: { row: Row<Monitor> }) { 69 - const { data, isLoading } = api.tinybird.httpGetMonthly.useQuery({ 70 - monitorId: row.original.monitorId, 71 - region: row.original.region, 72 - cronTimestamp: row.original.cronTimestamp || undefined, 73 - }); 74 - 75 - if (isLoading) 76 - return ( 77 - <div className="py-4"> 78 - <LoadingAnimation variant="inverse" /> 79 - </div> 80 - ); 81 - 82 - if (!data) return <p>Something went wrong</p>; 83 - 84 - const first = data.data?.[0]; 85 - 86 - // FIXME: ugly hack 87 - const url = new URL(window.location.href.replace("/data", "/details")); 88 - url.searchParams.set("monitorId", row.original.monitorId); 89 - url.searchParams.set("region", row.original.region); 90 - url.searchParams.set("cronTimestamp", String(row.original.cronTimestamp)); 91 - 92 - return ( 93 - <div className="relative"> 94 - <div className="absolute top-1 right-0"> 95 - <CopyToClipboardButton text={url.toString()} tooltipText="Copy link" /> 96 - </div> 97 - <ResponseDetailTabs 98 - timing={first.timing} 99 - headers={first.headers} 100 - status={first.statusCode} 101 - message={first.message} 102 - assertions={assertions.deserialize(first.assertions || "[]")} 103 - /> 104 - </div> 105 - ); 106 - }
-74
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/_components/download-csv-button.tsx
··· 1 - "use client"; 2 - 3 - import { Download } from "lucide-react"; 4 - 5 - import { 6 - Button, 7 - Tooltip, 8 - TooltipContent, 9 - TooltipProvider, 10 - TooltipTrigger, 11 - } from "@openstatus/ui"; 12 - 13 - import type { Monitor } from "./data-table-wrapper"; 14 - 15 - function jsonToCsv(jsonData: Record<string, unknown>[]): string { 16 - const csvRows: string[] = []; 17 - const headers = Object.keys(jsonData[0]); 18 - 19 - // Add header row 20 - csvRows.push(headers.join(",")); 21 - 22 - // Add data rows 23 - for (const row of jsonData) { 24 - const values = headers.map((header) => { 25 - const escaped = `${row[header]}`.replace(/"/g, '\\"'); 26 - return `"${escaped}"`; 27 - }); 28 - csvRows.push(values.join(",")); 29 - } 30 - 31 - return csvRows.join("\n"); 32 - } 33 - 34 - function downloadCsv(data: string, filename: string) { 35 - const blob = new Blob([data], { type: "text/csv" }); 36 - const link = document.createElement("a"); 37 - link.href = window.URL.createObjectURL(blob); 38 - link.setAttribute("download", filename); 39 - document.body.appendChild(link); 40 - link.click(); 41 - document.body.removeChild(link); 42 - } 43 - 44 - export function DownloadCSVButton({ 45 - data, 46 - filename, 47 - }: { 48 - data: Monitor[]; 49 - filename: string; 50 - }) { 51 - return ( 52 - <TooltipProvider> 53 - <Tooltip> 54 - <TooltipTrigger asChild> 55 - <Button 56 - variant="outline" 57 - size="icon" 58 - onClick={() => { 59 - const content = jsonToCsv(data); 60 - downloadCsv(content, filename); 61 - }} 62 - > 63 - <Download className="h-4 w-4" /> 64 - </Button> 65 - </TooltipTrigger> 66 - <TooltipContent> 67 - <p> 68 - Download <code>csv</code> file 69 - </p> 70 - </TooltipContent> 71 - </Tooltip> 72 - </TooltipProvider> 73 - ); 74 - }
-23
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 4 - 5 - export default function Loading() { 6 - return ( 7 - <div className="grid gap-4"> 8 - <div className="flex flex-row items-center justify-between"> 9 - <Skeleton className="h-10 w-[150px]" /> 10 - <Skeleton className="h-9 w-9" /> 11 - </div> 12 - <div className="grid gap-3"> 13 - <div className="flex items-center gap-2"> 14 - <Skeleton className="h-8 w-32" /> 15 - <Skeleton className="h-8 w-32" /> 16 - <Skeleton className="h-8 w-32" /> 17 - <Skeleton className="h-8 w-16" /> 18 - </div> 19 - <DataTableSkeleton rows={7} /> 20 - </div> 21 - </div> 22 - ); 23 - }
-63
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { DatePickerPreset } from "@/components/monitor-dashboard/date-picker-preset"; 4 - import { prepareListByPeriod } from "@/lib/tb"; 5 - import { api } from "@/trpc/server"; 6 - import { DataTableWrapper } from "./_components/data-table-wrapper"; 7 - import { searchParamsCache } from "./search-params"; 8 - 9 - export default async function Page(props: { 10 - params: Promise<{ workspaceSlug: string; id: string }>; 11 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 12 - }) { 13 - const searchParams = await props.searchParams; 14 - const params = await props.params; 15 - const id = params.id; 16 - const search = searchParamsCache.parse(searchParams); 17 - 18 - const monitor = await api.monitor.getMonitorById.query({ 19 - id: Number(id), 20 - }); 21 - 22 - if (!monitor) return notFound(); 23 - 24 - const type = monitor.jobType as "http" | "tcp"; 25 - 26 - // FIXME: make it dynamic based on the workspace plan 27 - const allowedPeriods = ["1d", "7d", "14d"] as const; 28 - const period = allowedPeriods.find((i) => i === search.period) || "1d"; 29 - 30 - const res = await prepareListByPeriod(period, type).getData({ 31 - monitorId: id, 32 - }); 33 - 34 - if (!res.data || res.data.length === 0) return null; 35 - 36 - return ( 37 - <div className="grid gap-4"> 38 - <div className="flex flex-row items-center justify-between gap-4"> 39 - <DatePickerPreset defaultValue={period} values={allowedPeriods} /> 40 - {/* <DownloadCSVButton 41 - data={data} 42 - filename={`${format(new Date(), "yyyy-mm-dd")}-${period}-${ 43 - monitor.name 44 - }`} 45 - /> */} 46 - </div> 47 - {/* FIXME: we display all the regions even though a user might not have all supported in their plan */} 48 - <DataTableWrapper 49 - data={res.data} 50 - filters={[ 51 - { id: "statusCode", value: search.statusCode }, 52 - { id: "region", value: search.regions }, 53 - { id: "error", value: search.error }, 54 - { id: "trigger", value: search.trigger }, 55 - ].filter((v) => v.value !== null)} 56 - pagination={{ 57 - pageIndex: search.pageIndex, 58 - pageSize: search.pageSize, 59 - }} 60 - /> 61 - </div> 62 - ); 63 - }
-23
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/data/search-params.ts
··· 1 - import { periods, triggers } from "@/lib/monitor/utils"; 2 - import { monitorRegions } from "@openstatus/db/src/schema/constants"; 3 - import { 4 - createSearchParamsCache, 5 - parseAsArrayOf, 6 - parseAsBoolean, 7 - parseAsInteger, 8 - parseAsStringLiteral, 9 - } from "nuqs/server"; 10 - 11 - export const DEFAULT_PERIOD = "1d"; 12 - 13 - export const searchParamsParsers = { 14 - statusCode: parseAsArrayOf(parseAsInteger), 15 - cronTimestamp: parseAsInteger, 16 - error: parseAsArrayOf(parseAsBoolean), 17 - period: parseAsStringLiteral(periods).withDefault(DEFAULT_PERIOD), 18 - regions: parseAsArrayOf(parseAsStringLiteral(monitorRegions)), 19 - pageSize: parseAsInteger.withDefault(10), 20 - pageIndex: parseAsInteger.withDefault(0), 21 - trigger: parseAsArrayOf(parseAsStringLiteral(triggers)), 22 - }; 23 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-42
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/details/page.tsx
··· 1 - import Link from "next/link"; 2 - 3 - import { Button } from "@openstatus/ui/src/components/button"; 4 - 5 - import { EmptyState } from "@/components/dashboard/empty-state"; 6 - import { ResponseDetails } from "@/components/monitor-dashboard/response-details"; 7 - import { api } from "@/trpc/server"; 8 - import { searchParamsCache } from "./search-params"; 9 - 10 - export default async function Details(props: { 11 - params: Promise<{ id: string; workspaceSlug: string }>; 12 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 13 - }) { 14 - const searchParams = await props.searchParams; 15 - const search = searchParamsCache.parse(searchParams); 16 - 17 - if (!search.monitorId) return <PageEmptyState />; 18 - 19 - try { 20 - await api.monitor.getMonitorById.query({ 21 - id: Number.parseInt(search.monitorId), 22 - }); 23 - return <ResponseDetails type="http" {...search} />; 24 - } catch (_e) { 25 - return <PageEmptyState />; 26 - } 27 - } 28 - 29 - function PageEmptyState() { 30 - return ( 31 - <EmptyState 32 - title="No log found" 33 - description="Seems like we couldn't find what you are looking for." 34 - icon="alert-triangle" 35 - action={ 36 - <Button asChild> 37 - <Link href="./data">Response Logs</Link> 38 - </Button> 39 - } 40 - /> 41 - ); 42 - }
-15
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/details/search-params.ts
··· 1 - import { monitorRegions } from "@openstatus/db/src/schema/constants"; 2 - import { 3 - createSearchParamsCache, 4 - parseAsInteger, 5 - parseAsString, 6 - parseAsStringLiteral, 7 - } from "nuqs/server"; 8 - 9 - export const searchParamsParsers = { 10 - monitorId: parseAsString.withDefault(""), 11 - url: parseAsString.withDefault(""), 12 - region: parseAsStringLiteral(monitorRegions).withDefault("ams"), 13 - cronTimestamp: parseAsInteger.withDefault(0), 14 - }; 15 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/edit/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 - 3 - export default function Loading() { 4 - return <SkeletonForm />; 5 - }
-53
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/edit/page.tsx
··· 1 - import { MonitorForm } from "@/components/forms/monitor/form"; 2 - import { api } from "@/trpc/server"; 3 - import { searchParamsCache } from "./search-params"; 4 - 5 - export default async function EditPage(props: { 6 - params: Promise<{ workspaceSlug: string; id: string }>; 7 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 8 - }) { 9 - const searchParams = await props.searchParams; 10 - const params = await props.params; 11 - const id = Number(params.id); 12 - const monitor = await api.monitor.getMonitorById.query({ id }); 13 - const workspace = await api.workspace.getWorkspace.query(); 14 - 15 - const monitorNotifications = 16 - await api.monitor.getAllNotificationsForMonitor.query({ id }); 17 - 18 - const notifications = 19 - await api.notification.getNotificationsByWorkspace.query(); 20 - 21 - const pages = await api.page.getPagesByWorkspace.query(); 22 - 23 - const tags = await api.monitorTag.getMonitorTagsByWorkspace.query(); 24 - 25 - const { section } = searchParamsCache.parse(searchParams); 26 - 27 - return ( 28 - <MonitorForm 29 - defaultSection={section} 30 - defaultValues={{ 31 - ...monitor, 32 - // FIXME - Why is this not working? 33 - degradedAfter: monitor.degradedAfter ?? undefined, 34 - pages: pages 35 - .filter((page) => 36 - page.monitorsToPages.map(({ monitorId }) => monitorId).includes(id), 37 - ) 38 - .map(({ id }) => id), 39 - notifications: monitorNotifications?.map(({ id }) => id), 40 - tags: tags 41 - .filter((tag) => 42 - tag.monitor.map(({ monitorId }) => monitorId).includes(id), 43 - ) 44 - .map(({ id }) => id), 45 - }} 46 - limits={workspace.limits} 47 - notifications={notifications} 48 - tags={tags} 49 - pages={pages} 50 - plan={workspace.plan} 51 - /> 52 - ); 53 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/edit/search-params.ts
··· 1 - import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 - 3 - export const searchParamsParsers = { 4 - section: parseAsString.withDefault("request"), 5 - }; 6 - 7 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-91
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/layout.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { Badge } from "@openstatus/ui/src/components/badge"; 4 - 5 - import { Header } from "@/components/dashboard/header"; 6 - import AppPageWithSidebarLayout from "@/components/layout/app-page-with-sidebar-layout"; 7 - import { CopyButton } from "@/components/layout/header/copy-button"; 8 - import { JobTypeIconWithTooltip } from "@/components/monitor/job-type-icon-with-tooltip"; 9 - import { NotificationIconWithTooltip } from "@/components/monitor/notification-icon-with-tooltip"; 10 - import { StatusDotWithTooltip } from "@/components/monitor/status-dot-with-tooltip"; 11 - import { TagBadgeWithTooltip } from "@/components/monitor/tag-badge-with-tooltip"; 12 - import { api } from "@/trpc/server"; 13 - 14 - export const revalidate = 0; // revalidate the data at most every hour 15 - export const dynamic = "force-dynamic"; 16 - export const fetchCache = "force-no-store"; 17 - 18 - export default async function Layout(props: { 19 - children: React.ReactNode; 20 - params: Promise<{ workspaceSlug: string; id: string }>; 21 - }) { 22 - const params = await props.params; 23 - 24 - const { children } = props; 25 - 26 - const id = params.id; 27 - 28 - const monitor = await api.monitor.getMonitorById.query({ 29 - id: Number(id), 30 - }); 31 - 32 - if (!monitor) { 33 - return notFound(); 34 - } 35 - 36 - return ( 37 - <AppPageWithSidebarLayout id="monitors"> 38 - <Header 39 - title={monitor.name} 40 - description={ 41 - <div className="flex flex-wrap items-center gap-2 text-muted-foreground"> 42 - <a 43 - href={monitor.url} 44 - target="_blank" 45 - rel="noreferrer" 46 - className="max-w-xs truncate text-base text-muted-foreground md:max-w-md" 47 - > 48 - {monitor.url} 49 - </a> 50 - <span className="text-muted-foreground/50 text-xs">•</span> 51 - <StatusDotWithTooltip 52 - active={monitor.active} 53 - status={monitor.status} 54 - maintenance={monitor.maintenance} 55 - /> 56 - {monitor.monitorTagsToMonitors.length > 0 ? ( 57 - <> 58 - <span className="text-muted-foreground/50 text-xs">•</span> 59 - <TagBadgeWithTooltip 60 - tags={monitor.monitorTagsToMonitors.map( 61 - ({ monitorTag }) => monitorTag, 62 - )} 63 - /> 64 - </> 65 - ) : null} 66 - <span className="text-muted-foreground/50 text-xs">•</span> 67 - <span className="text-sm"> 68 - every <code>{monitor.periodicity}</code> 69 - </span> 70 - <span className="text-muted-foreground/50 text-xs">•</span> 71 - <JobTypeIconWithTooltip jobType={monitor.jobType} /> 72 - {monitor.public ? ( 73 - <> 74 - <span className="text-muted-foreground/50 text-xs">•</span> 75 - <Badge variant="secondary">public</Badge> 76 - </> 77 - ) : null} 78 - <span className="text-muted-foreground/50 text-xs">•</span> 79 - <NotificationIconWithTooltip 80 - notifications={monitor.monitorsToNotifications.map( 81 - ({ notification }) => notification, 82 - )} 83 - /> 84 - </div> 85 - } 86 - actions={<CopyButton key="copy" id={monitor.id} />} 87 - /> 88 - {children} 89 - </AppPageWithSidebarLayout> 90 - ); 91 - }
-42
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/loading.tsx
··· 1 - import { Separator, Skeleton } from "@openstatus/ui"; 2 - 3 - export default function Loading() { 4 - return ( 5 - <div className="grid gap-4"> 6 - <Skeleton className="h-10 w-[150px]" /> 7 - <div className="grid gap-6"> 8 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 md:grid-cols-5 md:gap-6"> 9 - {new Array(4).fill(0).map((_, i) => ( 10 - <Skeleton key={i} className="h-16 w-full" /> 11 - ))} 12 - </div> 13 - <div className="grid gap-4"> 14 - <div className="grid grid-cols-2 gap-4 sm:grid-cols-4 md:grid-cols-5 md:gap-6"> 15 - {new Array(5).fill(0).map((_, i) => ( 16 - <Skeleton key={i} className="h-16 w-full" /> 17 - ))} 18 - </div> 19 - <Skeleton className="h-3 w-40" /> 20 - </div> 21 - </div> 22 - <Separator className="my-8" /> 23 - <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between"> 24 - <div className="flex w-full gap-2 sm:flex-row sm:justify-between"> 25 - <Skeleton className="h-10 w-[150px]" /> 26 - <Skeleton className="h-10 w-[150px]" /> 27 - </div> 28 - <div className="flex gap-2"> 29 - <div className="grid gap-1"> 30 - <Skeleton className="h-4 w-12" /> 31 - <Skeleton className="h-10 w-[150px]" /> 32 - </div> 33 - <div className="grid gap-1"> 34 - <Skeleton className="h-4 w-12" /> 35 - <Skeleton className="h-10 w-[150px]" /> 36 - </div> 37 - </div> 38 - </div> 39 - <Skeleton className="h-[396px] w-full" /> 40 - </div> 41 - ); 42 - }
-106
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { 4 - type Region, 5 - monitorRegions, 6 - } from "@openstatus/db/src/schema/constants"; 7 - import { Separator } from "@openstatus/ui"; 8 - 9 - import { CombinedChartWrapper } from "@/components/monitor-charts/combined-chart-wrapper"; 10 - import { ButtonReset } from "@/components/monitor-dashboard/button-reset"; 11 - import { DatePickerPreset } from "@/components/monitor-dashboard/date-picker-preset"; 12 - import { Metrics } from "@/components/monitor-dashboard/metrics"; 13 - import { getMinutesByInterval, periods } from "@/lib/monitor/utils"; 14 - import { getPreferredSettings } from "@/lib/preferred-settings/server"; 15 - import { 16 - prepareMetricByIntervalByPeriod, 17 - prepareMetricByRegionByPeriod, 18 - prepareMetricsByPeriod, 19 - } from "@/lib/tb"; 20 - import { api } from "@/trpc/server"; 21 - import { 22 - DEFAULT_INTERVAL, 23 - DEFAULT_PERIOD, 24 - DEFAULT_QUANTILE, 25 - searchParamsCache, 26 - } from "./search-params"; 27 - 28 - export default async function Page(props: { 29 - params: Promise<{ workspaceSlug: string; id: string }>; 30 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 31 - }) { 32 - const searchParams = await props.searchParams; 33 - const params = await props.params; 34 - const id = params.id; 35 - const search = searchParamsCache.parse(searchParams); 36 - 37 - const preferredSettings = getPreferredSettings(); 38 - 39 - const monitor = await api.monitor.getMonitorById.query({ 40 - id: Number(id), 41 - }); 42 - 43 - if (!monitor) return notFound(); 44 - 45 - const { period, quantile, interval, regions } = search; 46 - const type = monitor.jobType as "http" | "tcp"; 47 - 48 - // TODO: work it out easier 49 - const intervalMinutes = getMinutesByInterval(interval); 50 - const periodicityMinutes = getMinutesByInterval(monitor.periodicity); 51 - 52 - const isQuantileDisabled = intervalMinutes <= periodicityMinutes; 53 - const minutes = isQuantileDisabled ? periodicityMinutes : intervalMinutes; 54 - 55 - const [metrics, data, metricsByRegion] = await Promise.all([ 56 - prepareMetricsByPeriod(period, type).getData({ 57 - monitorId: id, 58 - }), 59 - prepareMetricByIntervalByPeriod(period, type).getData({ 60 - monitorId: id, 61 - interval: minutes, 62 - }), 63 - prepareMetricByRegionByPeriod(period, type).getData({ 64 - monitorId: id, 65 - }), 66 - ]); 67 - 68 - if (!data || !metrics || !metricsByRegion) return null; 69 - 70 - const isDirty = 71 - period !== DEFAULT_PERIOD || 72 - quantile !== DEFAULT_QUANTILE || 73 - interval !== DEFAULT_INTERVAL || 74 - monitorRegions.length !== regions.length; 75 - 76 - // GET VALUES FOR BLOG POST 77 - // console.log( 78 - // JSON.stringify({ 79 - // regions, 80 - // data: groupDataByTimestamp(data, period, quantile), 81 - // metricsByRegion, 82 - // }), 83 - // ); 84 - 85 - return ( 86 - <div className="grid gap-4"> 87 - <div className="flex justify-between gap-2"> 88 - <DatePickerPreset defaultValue={period} values={periods} /> 89 - {isDirty ? <ButtonReset /> : null} 90 - </div> 91 - <Metrics metrics={metrics.data} period={period} showErrorLink /> 92 - <Separator className="my-8" /> 93 - <CombinedChartWrapper 94 - data={data.data} 95 - period={period} 96 - quantile={quantile} 97 - interval={interval} 98 - regions={regions.length ? (regions as Region[]) : monitor.regions} // FIXME: not properly reseted after filtered 99 - monitor={monitor} 100 - isQuantileDisabled={isQuantileDisabled} 101 - metricsByRegion={metricsByRegion.data} 102 - preferredSettings={preferredSettings} 103 - /> 104 - </div> 105 - ); 106 - }
-24
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/overview/search-params.ts
··· 1 - import { intervals, periods, quantiles } from "@/lib/monitor/utils"; 2 - import { monitorRegions } from "@openstatus/db/src/schema/constants"; 3 - import { 4 - createSearchParamsCache, 5 - parseAsArrayOf, 6 - parseAsInteger, 7 - parseAsStringLiteral, 8 - } from "nuqs/server"; 9 - 10 - export const DEFAULT_QUANTILE = "p95"; 11 - export const DEFAULT_INTERVAL = "30m"; 12 - export const DEFAULT_PERIOD = "1d"; 13 - 14 - export const searchParamsParsers = { 15 - statusCode: parseAsInteger, 16 - cronTimestamp: parseAsInteger, 17 - quantile: parseAsStringLiteral(quantiles).withDefault(DEFAULT_QUANTILE), 18 - interval: parseAsStringLiteral(intervals).withDefault(DEFAULT_INTERVAL), 19 - period: parseAsStringLiteral(periods).withDefault(DEFAULT_PERIOD), 20 - regions: parseAsArrayOf(parseAsStringLiteral(monitorRegions)).withDefault([ 21 - ...monitorRegions, 22 - ]), 23 - }; 24 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-8
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/[id]/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - export default async function Page(props: { 4 - params: Promise<{ workspaceSlug: string; id: string }>; 5 - }) { 6 - const params = await props.params; 7 - return redirect(`./${params.id}/overview`); 8 - }
-15
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/new/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import AppPageLayout from "@/components/layout/app-page-layout"; 3 - 4 - export default async function Layout({ 5 - children, 6 - }: { 7 - children: React.ReactNode; 8 - }) { 9 - return ( 10 - <AppPageLayout> 11 - <Header title="Monitor" description="Create your monitor" /> 12 - {children} 13 - </AppPageLayout> 14 - ); 15 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/new/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 - 3 - export default function Loading() { 4 - return <SkeletonForm />; 5 - }
-34
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/new/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - import { MonitorForm } from "@/components/forms/monitor/form"; 4 - import { api } from "@/trpc/server"; 5 - import { searchParamsCache } from "./search-params"; 6 - 7 - export default async function Page(props: { 8 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 9 - }) { 10 - const searchParams = await props.searchParams; 11 - const workspace = await api.workspace.getWorkspace.query(); 12 - const notifications = 13 - await api.notification.getNotificationsByWorkspace.query(); 14 - const isLimitReached = await api.monitor.isMonitorLimitReached.query(); 15 - const tags = await api.monitorTag.getMonitorTagsByWorkspace.query(); 16 - 17 - const pages = await api.page.getPagesByWorkspace.query(); 18 - 19 - if (isLimitReached) return redirect("./"); 20 - 21 - const { section } = searchParamsCache.parse(searchParams); 22 - 23 - return ( 24 - <MonitorForm 25 - defaultSection={section} 26 - notifications={notifications} 27 - pages={pages} 28 - tags={tags} 29 - limits={workspace.limits} 30 - nextUrl="./" // back to the overview page 31 - plan={workspace.plan} 32 - /> 33 - ); 34 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/monitors/new/search-params.ts
··· 1 - import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 - 3 - export const searchParamsParsers = { 4 - section: parseAsString.withDefault("request"), 5 - }; 6 - 7 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-105
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/(overview)/_components/channel-table.tsx
··· 1 - import { env } from "@/env"; 2 - import type { Workspace } from "@openstatus/db/src/schema"; 3 - 4 - import { Button, Separator } from "@openstatus/ui"; 5 - import Link from "next/link"; 6 - 7 - // FIXME: create a Channel Component within the file to avoid code duplication 8 - 9 - interface ChannelTable { 10 - workspace: Workspace; 11 - disabled?: boolean; 12 - } 13 - 14 - export default function ChannelTable({ workspace, disabled }: ChannelTable) { 15 - return ( 16 - <div className="col-span-full w-full rounded-lg border border-border border-dashed bg-background p-8"> 17 - <h2 className="font-cal text-2xl">Channels</h2> 18 - <h3 className="text-muted-foreground">Connect all your channels</h3> 19 - <div className="mt-4 rounded-md border"> 20 - <Channel 21 - title="Discord" 22 - description="Send notifications to discord." 23 - href="./notifications/new/discord" 24 - disabled={disabled} 25 - /> 26 - <Separator /> 27 - <Channel 28 - title="Email" 29 - description="Send notifications by email." 30 - href="./notifications/new/email" 31 - disabled={disabled} 32 - /> 33 - <Separator /> 34 - <Channel 35 - title="ntfy.sh" 36 - description="Send notifications by ntfy.sh." 37 - href="./notifications/new/ntfy" 38 - disabled={disabled} 39 - /> 40 - <Separator /> 41 - <Channel 42 - title="OpsGenie" 43 - description="Send notifications to OpsGenie." 44 - href="./notifications/new/opsgenie" 45 - disabled={disabled || !workspace.limits.opsgenie} 46 - /> 47 - <Separator /> 48 - <Channel 49 - title="PagerDuty" 50 - description="Send notifications to PagerDuty." 51 - href={`https://app.pagerduty.com/install/integration?app_id=${env.PAGERDUTY_APP_ID}&redirect_url=${ 52 - process.env.NODE_ENV === "development" // FIXME: This sucks 53 - ? "http://localhost:3000" 54 - : "https://www.openstatus.dev" 55 - }/api/callback/pagerduty?workspace=${workspace.slug}&version=2`} 56 - disabled={disabled || !workspace.limits.pagerduty} 57 - /> 58 - <Separator /> 59 - <Channel 60 - title="Slack" 61 - description="Send notifications to Slack." 62 - href="./notifications/new/slack" 63 - disabled={disabled} 64 - /> 65 - <Separator /> 66 - <Channel 67 - title="SMS" 68 - description="Send notifications to your phones." 69 - href="./notifications/new/sms" 70 - disabled={disabled || !workspace.limits.sms} 71 - /> 72 - <Separator /> 73 - <Channel 74 - title="Webhook" 75 - description="Send notifications to your webhook." 76 - href="./notifications/new/webhook" 77 - disabled={disabled} 78 - /> 79 - </div> 80 - </div> 81 - ); 82 - } 83 - 84 - interface ChannelProps { 85 - title: string; 86 - description: string; 87 - href: string; 88 - disabled?: boolean; 89 - } 90 - 91 - function Channel({ title, description, href, disabled }: ChannelProps) { 92 - return ( 93 - <div className="flex items-center gap-4 px-4 py-3"> 94 - <div className="flex-1 space-y-1"> 95 - <p className="font-medium text-sm leading-none">{title}</p> 96 - <p className="text-muted-foreground text-sm">{description}</p> 97 - </div> 98 - <div> 99 - <Button disabled={disabled} asChild={!disabled}> 100 - {disabled ? "Create" : <Link href={href}>Create</Link>} 101 - </Button> 102 - </div> 103 - </div> 104 - ); 105 - }
-18
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/(overview)/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import AppPageLayout from "@/components/layout/app-page-layout"; 3 - 4 - export default async function Layout({ 5 - children, 6 - }: { 7 - children: React.ReactNode; 8 - }) { 9 - return ( 10 - <AppPageLayout> 11 - <Header 12 - title="Notifications" 13 - description="Overview of all your notification channels." 14 - /> 15 - {children} 16 - </AppPageLayout> 17 - ); 18 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/(overview)/loading.tsx
··· 1 - import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 2 - 3 - export default function Loading() { 4 - return <DataTableSkeleton />; 5 - }
-35
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/(overview)/page.tsx
··· 1 - import { EmptyState } from "@/components/dashboard/empty-state"; 2 - import { Limit } from "@/components/dashboard/limit"; 3 - import { columns } from "@/components/data-table/notification/columns"; 4 - import { DataTable } from "@/components/data-table/notification/data-table"; 5 - import { api } from "@/trpc/server"; 6 - import ChannelTable from "./_components/channel-table"; 7 - 8 - export default async function NotificationPage() { 9 - const [workspace, notifications, isLimitReached] = await Promise.all([ 10 - api.workspace.getWorkspace.query(), 11 - api.notification.getNotificationsByWorkspace.query(), 12 - api.notification.isNotificationLimitReached.query(), 13 - ]); 14 - 15 - if (notifications.length === 0) { 16 - return ( 17 - <> 18 - <EmptyState 19 - icon="bell" 20 - title="No notifications" 21 - description="Create your first notification channel" 22 - /> 23 - <ChannelTable workspace={workspace} disabled={isLimitReached} /> 24 - </> 25 - ); 26 - } 27 - 28 - return ( 29 - <> 30 - <DataTable columns={columns} data={notifications} /> 31 - {isLimitReached ? <Limit /> : null} 32 - <ChannelTable workspace={workspace} disabled={isLimitReached} /> 33 - </> 34 - ); 35 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/[id]/edit/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 - 3 - export default function Loading() { 4 - return <SkeletonForm />; 5 - }
-25
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/[id]/edit/page.tsx
··· 1 - import { NotificationForm } from "@/components/forms/notification/form"; 2 - import { api } from "@/trpc/server"; 3 - 4 - export default async function EditPage(props: { 5 - params: Promise<{ workspaceSlug: string; id: string }>; 6 - }) { 7 - const params = await props.params; 8 - const [workspace, monitors, notification] = await Promise.all([ 9 - api.workspace.getWorkspace.query(), 10 - api.monitor.getMonitorsByWorkspace.query(), 11 - api.notification.getNotificationById.query({ id: Number(params.id) }), 12 - ]); 13 - 14 - return ( 15 - <NotificationForm 16 - defaultValues={{ 17 - ...notification, 18 - monitors: notification.monitor.map(({ monitor }) => monitor.id), 19 - }} 20 - monitors={monitors} 21 - workspacePlan={workspace.plan} 22 - provider={notification.provider} 23 - /> 24 - ); 25 - }
-36
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/[id]/layout.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { Header } from "@/components/dashboard/header"; 4 - import AppPageWithSidebarLayout from "@/components/layout/app-page-with-sidebar-layout"; 5 - import { CopyButton } from "@/components/layout/header/copy-button"; 6 - import { api } from "@/trpc/server"; 7 - 8 - export default async function Layout(props: { 9 - children: React.ReactNode; 10 - params: Promise<{ workspaceSlug: string; id: string }>; 11 - }) { 12 - const params = await props.params; 13 - 14 - const { children } = props; 15 - 16 - const id = params.id; 17 - 18 - const notification = await api.notification.getNotificationById.query({ 19 - id: Number(id), 20 - }); 21 - 22 - if (!notification) { 23 - return notFound(); 24 - } 25 - 26 - return ( 27 - <AppPageWithSidebarLayout id="notifications"> 28 - <Header 29 - title={notification.name} 30 - description={<span className="font-mono">{notification.provider}</span>} 31 - actions={<CopyButton key="copy" id={notification.id} />} 32 - /> 33 - {children} 34 - </AppPageWithSidebarLayout> 35 - ); 36 - }
-8
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/[id]/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - export default async function Page(props: { 4 - params: Promise<{ workspaceSlug: string; id: string }>; 5 - }) { 6 - const params = await props.params; 7 - return redirect(`./${params.id}/edit`); 8 - }
-43
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/new/[channel]/page.tsx
··· 1 - import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 2 - import { NotificationForm } from "@/components/forms/notification/form"; 3 - import { api } from "@/trpc/server"; 4 - import { notificationProviderSchema } from "@openstatus/db/src/schema"; 5 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 6 - 7 - import { notFound } from "next/navigation"; 8 - 9 - export default async function ChannelPage(props: { 10 - params: Promise<{ channel: string }>; 11 - }) { 12 - const params = await props.params; 13 - const validation = notificationProviderSchema 14 - .exclude(["pagerduty"]) 15 - .safeParse(params.channel); 16 - 17 - if (!validation.success) notFound(); 18 - 19 - const workspace = await api.workspace.getWorkspace.query(); 20 - const monitors = await api.monitor.getMonitorsByWorkspace.query(); 21 - 22 - const provider = validation.data; 23 - 24 - const allowed = 25 - provider === "sms" ? getLimit(workspace.limits, provider) : true; 26 - 27 - if (!allowed) return <ProFeatureAlert feature="SMS channel notification" />; 28 - 29 - const isLimitReached = 30 - await api.notification.isNotificationLimitReached.query(); 31 - 32 - if (isLimitReached) { 33 - return <ProFeatureAlert feature="More notification channel" />; 34 - } 35 - return ( 36 - <NotificationForm 37 - workspacePlan={workspace.plan} 38 - nextUrl="../" 39 - provider={provider} 40 - monitors={monitors} 41 - /> 42 - ); 43 - }
-18
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/new/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import AppPageLayout from "@/components/layout/app-page-layout"; 3 - 4 - export default async function Layout({ 5 - children, 6 - }: { 7 - children: React.ReactNode; 8 - }) { 9 - return ( 10 - <AppPageLayout> 11 - <Header 12 - title="Notifications" 13 - description="Add your a new notification channel " 14 - /> 15 - {children} 16 - </AppPageLayout> 17 - ); 18 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/new/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 - 3 - export default function Loading() { 4 - return <SkeletonForm />; 5 - }
-43
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/new/pagerduty/page.tsx
··· 1 - import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 2 - import { NotificationForm } from "@/components/forms/notification/form"; 3 - import { api } from "@/trpc/server"; 4 - import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 5 - import { PagerDutySchema } from "@openstatus/notification-pagerduty"; 6 - 7 - import { searchParamsCache } from "./search-params"; 8 - 9 - // REMINDER: PagerDuty requires a different workflow, thus the separate page 10 - 11 - export default async function PagerDutyPage(props: { 12 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 13 - }) { 14 - const searchParams = await props.searchParams; 15 - const workspace = await api.workspace.getWorkspace.query(); 16 - const monitors = await api.monitor.getMonitorsByWorkspace.query(); 17 - const params = searchParamsCache.parse(searchParams); 18 - 19 - if (!params.config) { 20 - return <div>Invalid data</div>; 21 - } 22 - 23 - const data = PagerDutySchema.parse(JSON.parse(params.config)); 24 - if (!data) { 25 - return <div>Invalid data</div>; 26 - } 27 - 28 - const allowed = getLimit(workspace.limits, "pagerduty"); 29 - if (!allowed) 30 - return <ProFeatureAlert feature="PagerDuty channel notification" />; 31 - 32 - return ( 33 - <> 34 - <NotificationForm 35 - workspacePlan={workspace.plan} 36 - nextUrl="../" 37 - provider="pagerduty" 38 - callbackData={params.config} 39 - monitors={monitors} 40 - /> 41 - </> 42 - ); 43 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/notifications/new/pagerduty/search-params.ts
··· 1 - import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 - 3 - export const searchParamsParsers = { 4 - config: parseAsString, 5 - }; 6 - 7 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-8
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - export default async function DashboardRedirect(props: { 4 - params: Promise<{ workspaceSlug: string }>; 5 - }) { 6 - const params = await props.params; 7 - return redirect(`/app/${params.workspaceSlug}/monitors`); 8 - }
-50
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/actions.ts
··· 1 - "use server"; 2 - 3 - import { Unkey } from "@unkey/api"; 4 - 5 - import { db, eq } from "@openstatus/db"; 6 - import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 7 - 8 - import { env } from "@/env"; 9 - import { auth } from "@/lib/auth"; 10 - import { revalidatePath } from "next/cache"; 11 - 12 - const unkey = new Unkey({ token: env.UNKEY_TOKEN, cache: "no-cache" }); 13 - 14 - // REMINDER: server actions should have middlewares to do auth checks 15 - 16 - export async function create(ownerId: number) { 17 - const session = await auth(); 18 - 19 - if (!session?.user?.id) return; 20 - 21 - const allowedWorkspaces = await db 22 - .select() 23 - .from(usersToWorkspaces) 24 - .innerJoin(user, eq(user.id, usersToWorkspaces.userId)) 25 - .innerJoin(workspace, eq(workspace.id, usersToWorkspaces.workspaceId)) 26 - .where(eq(user.id, Number.parseInt(session.user.id))) 27 - .all(); 28 - 29 - const allowedIds = allowedWorkspaces.map((i) => i.workspace.id); 30 - 31 - if (!allowedIds.includes(ownerId)) return; 32 - 33 - const key = await unkey.keys.create({ 34 - apiId: env.UNKEY_API_ID, 35 - ownerId: String(ownerId), 36 - prefix: "os", 37 - }); 38 - 39 - revalidatePath("/app/[workspaceSlug]/(dashboard)/settings/api-token"); 40 - 41 - return key; 42 - } 43 - 44 - export async function revoke(keyId: string) { 45 - const res = await unkey.keys.delete({ keyId }); 46 - 47 - revalidatePath("/app/[workspaceSlug]/(dashboard)/settings/api-token"); 48 - 49 - return res; 50 - }
-57
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/card.tsx
··· 1 - import { Container } from "@/components/dashboard/container"; 2 - import { formatDate } from "@/lib/utils"; 3 - import { CreateForm } from "./create-form"; 4 - import { RevokeButton } from "./revoke-button"; 5 - 6 - export async function ApiKeys({ 7 - ownerId, 8 - value, 9 - }: { 10 - ownerId: number; 11 - value?: { id: string; start: string; createdAt: number }; 12 - }) { 13 - return ( 14 - <> 15 - <Container 16 - title="API Token" 17 - description="Use our API endpoints to create your monitors programmatically." 18 - actions={ 19 - <> 20 - {value ? ( 21 - <RevokeButton keyId={value.id} /> 22 - ) : ( 23 - <CreateForm ownerId={ownerId} /> 24 - )} 25 - </> 26 - } 27 - > 28 - {value ? ( 29 - <dl className="grid gap-2 *:text-sm [&_dt]:font-light [&_dt]:text-muted-foreground"> 30 - <div className="flex min-w-0 items-center justify-between gap-3"> 31 - <dt>Token</dt> 32 - <dd className="font-mono">{value.start}...</dd> 33 - </div> 34 - <div className="flex min-w-0 items-center justify-between gap-3"> 35 - <dt>Created At</dt> 36 - <dd> 37 - {value.createdAt && formatDate(new Date(value.createdAt))} 38 - </dd> 39 - </div> 40 - </dl> 41 - ) : null} 42 - </Container> 43 - <p className="text-foreground text-sm"> 44 - Read more about API in our{" "} 45 - <a 46 - className="text-foreground underline underline-offset-4 hover:no-underline" 47 - href="https://api.openstatus.dev/v1" 48 - target="_blank" 49 - rel="noreferrer" 50 - > 51 - docs 52 - </a> 53 - . 54 - </p> 55 - </> 56 - ); 57 - }
-95
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/create-form.tsx
··· 1 - "use client"; 2 - 3 - import { useRouter } from "next/navigation"; 4 - import * as React from "react"; 5 - 6 - import { 7 - AlertDialog, 8 - AlertDialogAction, 9 - AlertDialogContent, 10 - AlertDialogDescription, 11 - AlertDialogFooter, 12 - AlertDialogHeader, 13 - AlertDialogTitle, 14 - } from "@openstatus/ui"; 15 - 16 - import { Icons } from "@/components/icons"; 17 - import { toastAction } from "@/lib/toast"; 18 - import { copyToClipboard } from "@/lib/utils"; 19 - import { create } from "./actions"; 20 - import { SubmitButton } from "./submit-button"; 21 - 22 - export function CreateForm({ ownerId }: { ownerId: number }) { 23 - const [rawKey, setRawKey] = React.useState<string | undefined>(); 24 - const router = useRouter(); 25 - const [hasCopied, setHasCopied] = React.useState(false); 26 - 27 - React.useEffect(() => { 28 - if (hasCopied) { 29 - setTimeout(() => { 30 - setHasCopied(false); 31 - }, 2000); 32 - } 33 - }, [hasCopied]); 34 - 35 - async function onCreate() { 36 - try { 37 - const res = await create(ownerId); 38 - if (!res) toastAction("error"); 39 - if (res?.result) { 40 - setRawKey(res.result.key); 41 - } 42 - } catch { 43 - toastAction("error"); 44 - } 45 - } 46 - 47 - return ( 48 - <> 49 - <form action={onCreate}> 50 - <SubmitButton>Create</SubmitButton> 51 - </form> 52 - <AlertDialog 53 - open={Boolean(rawKey)} 54 - onOpenChange={() => setRawKey(undefined)} 55 - > 56 - <AlertDialogContent> 57 - <AlertDialogHeader> 58 - <AlertDialogTitle>Api Key</AlertDialogTitle> 59 - <AlertDialogDescription> 60 - Please make sure to store the API key safely. You will only be 61 - able to see it once. If you forgot to safe it, you will need to 62 - revoke and recreate a key. 63 - </AlertDialogDescription> 64 - </AlertDialogHeader> 65 - <div> 66 - <button 67 - type="button" 68 - className="group inline-flex items-center p-2" 69 - onClick={() => { 70 - copyToClipboard(String(rawKey)); 71 - setHasCopied(true); 72 - }} 73 - > 74 - <span className="ph-no-capture font-mono">{rawKey}</span> 75 - {!hasCopied ? ( 76 - <Icons.copy className="ml-2 hidden h-4 w-4 group-hover:block" /> 77 - ) : ( 78 - <Icons.check className="ml-2 h-4 w-4" /> 79 - )} 80 - </button> 81 - </div> 82 - <AlertDialogFooter> 83 - <AlertDialogAction 84 - onClick={() => { 85 - router.refresh(); 86 - }} 87 - > 88 - Continue 89 - </AlertDialogAction> 90 - </AlertDialogFooter> 91 - </AlertDialogContent> 92 - </AlertDialog> 93 - </> 94 - ); 95 - }
-70
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/revoke-button.tsx
··· 1 - "use client"; 2 - 3 - import { useRouter } from "next/navigation"; 4 - import * as React from "react"; 5 - 6 - import { 7 - AlertDialog, 8 - AlertDialogAction, 9 - AlertDialogCancel, 10 - AlertDialogContent, 11 - AlertDialogDescription, 12 - AlertDialogFooter, 13 - AlertDialogHeader, 14 - AlertDialogTitle, 15 - AlertDialogTrigger, 16 - Button, 17 - } from "@openstatus/ui"; 18 - 19 - import { LoadingAnimation } from "@/components/loading-animation"; 20 - import { toastAction } from "@/lib/toast"; 21 - import { revoke } from "./actions"; 22 - 23 - export function RevokeButton({ keyId }: { keyId: string }) { 24 - const [open, setOpen] = React.useState(false); 25 - const [isPending, startTransition] = React.useTransition(); 26 - const router = useRouter(); 27 - 28 - async function onRevoke() { 29 - startTransition(async () => { 30 - try { 31 - await revoke(keyId); 32 - router.refresh(); 33 - setOpen(false); 34 - toastAction("deleted"); 35 - } catch { 36 - toastAction("error"); 37 - } 38 - }); 39 - } 40 - 41 - return ( 42 - <AlertDialog open={open} onOpenChange={(value) => setOpen(value)}> 43 - <AlertDialogTrigger asChild> 44 - <Button variant="destructive">Revoke</Button> 45 - </AlertDialogTrigger> 46 - <AlertDialogContent> 47 - <AlertDialogHeader> 48 - <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> 49 - <AlertDialogDescription> 50 - This action cannot be undone. This will permanently revoke the API 51 - key. 52 - </AlertDialogDescription> 53 - </AlertDialogHeader> 54 - <AlertDialogFooter> 55 - <AlertDialogCancel>Cancel</AlertDialogCancel> 56 - <AlertDialogAction 57 - onClick={async (e) => { 58 - e.preventDefault(); 59 - await onRevoke(); 60 - }} 61 - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" 62 - disabled={isPending} 63 - > 64 - {!isPending ? "Delete" : <LoadingAnimation />} 65 - </AlertDialogAction> 66 - </AlertDialogFooter> 67 - </AlertDialogContent> 68 - </AlertDialog> 69 - ); 70 - }
-16
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/api-token/_components/submit-button.tsx
··· 1 - "use client"; 2 - 3 - import { useFormStatus } from "react-dom"; 4 - 5 - import { Button } from "@openstatus/ui/src/components/button"; 6 - 7 - import { LoadingAnimation } from "@/components/loading-animation"; 8 - 9 - export function SubmitButton({ children }: { children?: React.ReactNode }) { 10 - const { pending } = useFormStatus(); 11 - return ( 12 - <Button type="submit" disabled={pending} className="disabled:opacity-100"> 13 - {pending ? <LoadingAnimation /> : children} 14 - </Button> 15 - ); 16 - }
-13
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/api-token/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import { getPageBySegment } from "@/config/pages"; 3 - 4 - const page = getPageBySegment(["settings", "api-token"]); 5 - 6 - export default function Layout({ children }: { children: React.ReactNode }) { 7 - return ( 8 - <> 9 - <Header title={page?.title} description={page?.description} /> 10 - {children} 11 - </> 12 - ); 13 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/api-token/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - // TODO: can be improved... 4 - 5 - export default function Loading() { 6 - return <Skeleton className="h-72 w-full" />; 7 - }
-26
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/api-token/page.tsx
··· 1 - import { Unkey } from "@unkey/api"; 2 - 3 - import { env } from "@/env"; 4 - import { api } from "@/trpc/server"; 5 - import { ApiKeys } from "./_components/card"; 6 - 7 - export const revalidate = 0; 8 - 9 - const unkey = new Unkey({ token: env.UNKEY_TOKEN, cache: "no-cache" }); 10 - 11 - export default async function ApiTokenPage() { 12 - const workspace = await api.workspace.getWorkspace.query(); 13 - 14 - const data = await unkey.apis.listKeys({ 15 - apiId: env.UNKEY_API_ID, 16 - ownerId: String(workspace.id), 17 - }); 18 - 19 - if (data.error) { 20 - return <div>Something went wrong. Please contact us.</div>; 21 - } 22 - 23 - const value = data.result.keys?.[0] || undefined; 24 - 25 - return <ApiKeys ownerId={workspace.id} value={value} />; 26 - }
-13
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/appearance/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import { getPageBySegment } from "@/config/pages"; 3 - 4 - const page = getPageBySegment(["settings", "appearance"]); 5 - 6 - export default function Layout({ children }: { children: React.ReactNode }) { 7 - return ( 8 - <> 9 - <Header title={page?.title} description={page?.description} /> 10 - {children} 11 - </> 12 - ); 13 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/appearance/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - // TODO: can be improved... 4 - 5 - export default function Loading() { 6 - return <Skeleton className="h-72 w-full" />; 7 - }
-96
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/appearance/page.tsx
··· 1 - "use client"; 2 - 3 - import { useTheme } from "next-themes"; 4 - 5 - import { cn } from "@/lib/utils"; 6 - 7 - // TODO: improve keyboard navigation 8 - 9 - export default function AppearancePage() { 10 - const { setTheme, theme } = useTheme(); 11 - 12 - return ( 13 - <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4"> 14 - <button type="button" onClick={() => setTheme("light")}> 15 - <LightModeCard active={theme === "light"} /> 16 - <span className="mt-2 font-light text-muted-foreground text-sm"> 17 - Light 18 - </span> 19 - </button> 20 - <button type="button" onClick={() => setTheme("dark")}> 21 - <DarkModeCard active={theme === "dark"} /> 22 - <span className="mt-2 font-light text-muted-foreground text-sm"> 23 - Dark 24 - </span> 25 - </button> 26 - <button type="button" onClick={() => setTheme("system")}> 27 - <div className="relative"> 28 - <LightModeCard active={theme === "system"} /> 29 - <div 30 - className="absolute top-0 right-0 bottom-0 left-0" 31 - style={{ 32 - clipPath: "polygon(100% 0, 0 0, 100% 100%)", 33 - }} 34 - > 35 - <DarkModeCard active={theme === "system"} /> 36 - </div> 37 - </div> 38 - <span className="mt-2 font-light text-muted-foreground text-sm"> 39 - System 40 - </span> 41 - </button> 42 - </div> 43 - ); 44 - } 45 - 46 - function LightModeCard({ active }: { active: boolean }) { 47 - return ( 48 - <div 49 - className={cn( 50 - "items-center rounded-md border-2 border-muted p-1", 51 - active && "ring-2 ring-ring ring-offset-2 ring-offset-background", 52 - )} 53 - > 54 - <div className="space-y-2 rounded-sm bg-[#ecedef] p-2"> 55 - <div className="space-y-2 rounded-md bg-white p-2 shadow-xs"> 56 - <div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" /> 57 - <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" /> 58 - </div> 59 - <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs"> 60 - <div className="h-4 w-4 rounded-full bg-[#ecedef]" /> 61 - <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" /> 62 - </div> 63 - <div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs"> 64 - <div className="h-4 w-4 rounded-full bg-[#ecedef]" /> 65 - <div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" /> 66 - </div> 67 - </div> 68 - </div> 69 - ); 70 - } 71 - 72 - function DarkModeCard({ active }: { active: boolean }) { 73 - return ( 74 - <div 75 - className={cn( 76 - "items-center rounded-md border-2 border-muted bg-popover p-1", 77 - active && "ring-2 ring-ring ring-offset-2 ring-offset-background", 78 - )} 79 - > 80 - <div className="space-y-2 rounded-sm bg-slate-950 p-2"> 81 - <div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-xs"> 82 - <div className="h-2 w-[80px] rounded-lg bg-slate-400" /> 83 - <div className="h-2 w-[100px] rounded-lg bg-slate-400" /> 84 - </div> 85 - <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs"> 86 - <div className="h-4 w-4 rounded-full bg-slate-400" /> 87 - <div className="h-2 w-[100px] rounded-lg bg-slate-400" /> 88 - </div> 89 - <div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs"> 90 - <div className="h-4 w-4 rounded-full bg-slate-400" /> 91 - <div className="h-2 w-[100px] rounded-lg bg-slate-400" /> 92 - </div> 93 - </div> 94 - </div> 95 - ); 96 - }
-81
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/billing/_components/change-plan-button.tsx
··· 1 - "use client"; 2 - 3 - import { TRPCClientError } from "@trpc/client"; 4 - import { useRouter } from "next/navigation"; 5 - import { useState, useTransition } from "react"; 6 - 7 - import { workspacePlans } from "@openstatus/db/src/schema"; 8 - import type { Workspace, WorkspacePlan } from "@openstatus/db/src/schema"; 9 - import { 10 - Button, 11 - Dialog, 12 - DialogContent, 13 - DialogDescription, 14 - DialogFooter, 15 - DialogHeader, 16 - DialogTitle, 17 - DialogTrigger, 18 - } from "@openstatus/ui"; 19 - 20 - import { LoadingAnimation } from "@/components/loading-animation"; 21 - import { toast } from "@/lib/toast"; 22 - import { api } from "@/trpc/client"; 23 - 24 - export function ChangePlanButton({ workspace }: { workspace: Workspace }) { 25 - const [open, setOpen] = useState(false); 26 - const [isPending, startTransition] = useTransition(); 27 - const router = useRouter(); 28 - 29 - function onChange(plan: WorkspacePlan) { 30 - startTransition(async () => { 31 - try { 32 - await api.workspace.changePlan.mutate({ plan }); 33 - } catch (e) { 34 - if (e instanceof TRPCClientError) { 35 - toast.error(e.message); 36 - } 37 - } finally { 38 - setOpen(false); 39 - router.refresh(); 40 - } 41 - }); 42 - } 43 - 44 - return ( 45 - <Dialog open={open} onOpenChange={setOpen}> 46 - <DialogTrigger asChild> 47 - <Button variant={workspace.plan === "free" ? "default" : "outline"}> 48 - Change Plan 49 - </Button> 50 - </DialogTrigger> 51 - <DialogContent> 52 - <DialogHeader> 53 - <DialogTitle>Change plan</DialogTitle> 54 - <DialogDescription> 55 - You are currently on the{" "} 56 - <span className="font-bold">{workspace.plan}</span> plan. 57 - </DialogDescription> 58 - </DialogHeader> 59 - <DialogFooter className="grid w-full grid-cols-4 gap-3"> 60 - {workspacePlans.map((plan) => { 61 - const isActive = plan === workspace.plan; 62 - return ( 63 - <Button 64 - key={plan} 65 - onClick={() => onChange(plan)} 66 - disabled={isPending || isActive} 67 - variant="outline" 68 - > 69 - {isPending && !isActive ? ( 70 - <LoadingAnimation variant="inverse" /> 71 - ) : ( 72 - plan 73 - )} 74 - </Button> 75 - ); 76 - })} 77 - </DialogFooter> 78 - </DialogContent> 79 - </Dialog> 80 - ); 81 - }
-40
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/billing/_components/customer-portal-button.tsx
··· 1 - "use client"; 2 - 3 - import { useRouter } from "next/navigation"; 4 - import { useTransition } from "react"; 5 - 6 - import { Button } from "@openstatus/ui/src/components/button"; 7 - 8 - import { LoadingAnimation } from "@/components/loading-animation"; 9 - import { api } from "@/trpc/client"; 10 - 11 - interface Props { 12 - workspaceSlug: string; 13 - } 14 - 15 - export function CustomerPortalButton({ workspaceSlug }: Props) { 16 - const router = useRouter(); 17 - const [isPending, startTransition] = useTransition(); 18 - 19 - const getUserCustomerPortal = () => { 20 - startTransition(async () => { 21 - const url = await api.stripeRouter.getUserCustomerPortal.mutate({ 22 - workspaceSlug, 23 - }); 24 - if (!url) return; 25 - router.push(url); 26 - return; 27 - }); 28 - }; 29 - 30 - return ( 31 - <Button 32 - size="sm" 33 - variant="secondary" 34 - onClick={getUserCustomerPortal} 35 - disabled={isPending} 36 - > 37 - {isPending ? <LoadingAnimation variant="inverse" /> : "Customer Portal"} 38 - </Button> 39 - ); 40 - }
-54
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/billing/_components/plan.tsx
··· 1 - "use client"; 2 - 3 - import { useRouter } from "next/navigation"; 4 - import { useTransition } from "react"; 5 - 6 - import type { Workspace, WorkspacePlan } from "@openstatus/db/src/schema"; 7 - 8 - import { PricingTable } from "@/components/marketing/pricing/pricing-table"; 9 - import { getStripe } from "@/lib/stripe/client"; 10 - import { api } from "@/trpc/client"; 11 - 12 - export const SettingsPlan = ({ workspace }: { workspace: Workspace }) => { 13 - const router = useRouter(); 14 - const [isPending, startTransition] = useTransition(); 15 - 16 - const getCheckoutSession = (plan: WorkspacePlan) => { 17 - startTransition(async () => { 18 - const result = await api.stripeRouter.getCheckoutSession.mutate({ 19 - workspaceSlug: workspace.slug, 20 - plan, 21 - }); 22 - if (!result) return; 23 - 24 - const stripe = await getStripe(); 25 - stripe?.redirectToCheckout({ 26 - sessionId: result.id, 27 - }); 28 - }); 29 - }; 30 - 31 - const getUserCustomerPortal = () => { 32 - startTransition(async () => { 33 - const url = await api.stripeRouter.getUserCustomerPortal.mutate({ 34 - workspaceSlug: workspace.slug, 35 - }); 36 - if (!url) return; 37 - router.push(url); 38 - return; 39 - }); 40 - }; 41 - 42 - return ( 43 - <PricingTable 44 - currentPlan={workspace.plan} 45 - isLoading={isPending} 46 - events={{ 47 - // REMINDER: redirecting to customer portal as a fallback because the free plan has no price 48 - free: getUserCustomerPortal, 49 - starter: () => getCheckoutSession("starter"), 50 - team: () => getCheckoutSession("team"), 51 - }} 52 - /> 53 - ); 54 - };
-13
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/billing/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import { getPageBySegment } from "@/config/pages"; 3 - 4 - const page = getPageBySegment(["settings", "billing"]); 5 - 6 - export default function Layout({ children }: { children: React.ReactNode }) { 7 - return ( 8 - <> 9 - <Header title={page?.title} description={page?.description} /> 10 - {children} 11 - </> 12 - ); 13 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/billing/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - // TODO: can be improved... 4 - 5 - export default function Loading() { 6 - return <Skeleton className="h-72 w-full" />; 7 - }
-42
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/billing/page.tsx
··· 1 - import { Progress, Separator } from "@openstatus/ui"; 2 - 3 - import { api } from "@/trpc/server"; 4 - import { CustomerPortalButton } from "./_components/customer-portal-button"; 5 - import { SettingsPlan } from "./_components/plan"; 6 - 7 - export default async function BillingPage() { 8 - const workspace = await api.workspace.getWorkspace.query(); 9 - const currentNumbers = await api.workspace.getCurrentWorkspaceNumbers.query(); 10 - 11 - return ( 12 - <div className="grid gap-4"> 13 - <div className="flex items-center justify-between gap-3"> 14 - <h3 className="font-medium text-lg"> 15 - <span className="capitalize">{workspace.plan}</span> plan 16 - </h3> 17 - <CustomerPortalButton workspaceSlug={workspace.slug} /> 18 - </div> 19 - <div className="grid max-w-lg gap-3"> 20 - {Object.entries(currentNumbers).map(([key, value]) => { 21 - const limit = workspace.limits[key as keyof typeof currentNumbers]; 22 - // TODO: find a better way to determine if the limit is monthly 23 - const isMonthly = ["synthetic-checks"].includes(key); 24 - return ( 25 - <div key={key}> 26 - <div className="mb-1 flex items-center justify-between text-muted-foreground"> 27 - <p className="text-sm capitalize">{key.replace("-", " ")}</p> 28 - <p className="text-xs"> 29 - {isMonthly ? "monthly" : null}{" "} 30 - <span className="text-foreground">{value}</span> / {limit} 31 - </p> 32 - </div> 33 - <Progress value={(value / limit) * 100} /> 34 - </div> 35 - ); 36 - })} 37 - </div> 38 - <Separator className="my-4" /> 39 - <SettingsPlan workspace={workspace} /> 40 - </div> 41 - ); 42 - }
-43
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/general/_components/copy-to-clipboard-button.tsx
··· 1 - "use client"; 2 - 3 - import * as React from "react"; 4 - 5 - import type { ButtonProps } from "@openstatus/ui"; 6 - import { Button } from "@openstatus/ui/src/components/button"; 7 - 8 - import { Icons } from "@/components/icons"; 9 - import { cn, copyToClipboard } from "@/lib/utils"; 10 - 11 - export function CopyToClipboardButton({ 12 - children, 13 - onClick, 14 - ...props 15 - }: ButtonProps) { 16 - const [hasCopied, setHasCopied] = React.useState(false); 17 - 18 - React.useEffect(() => { 19 - if (hasCopied) { 20 - setTimeout(() => { 21 - setHasCopied(false); 22 - }, 2000); 23 - } 24 - }, [hasCopied]); 25 - 26 - return ( 27 - <Button 28 - onClick={(e) => { 29 - copyToClipboard(children?.toString() || ""); 30 - setHasCopied(true); 31 - onClick?.(e); 32 - }} 33 - {...props} 34 - > 35 - {children} 36 - {!hasCopied ? ( 37 - <Icons.copy className="ml-2 hidden h-4 w-4 group-hover:block" /> 38 - ) : ( 39 - <Icons.check className={cn("ml-2 h-4 w-4")} /> 40 - )} 41 - </Button> 42 - ); 43 - }
-13
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/general/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import { getPageBySegment } from "@/config/pages"; 3 - 4 - const page = getPageBySegment(["settings", "general"]); 5 - 6 - export default function Layout({ children }: { children: React.ReactNode }) { 7 - return ( 8 - <> 9 - <Header title={page?.title} description={page?.description} /> 10 - {children} 11 - </> 12 - ); 13 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/general/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - // TODO: can be improved... 4 - 5 - export default function Loading() { 6 - return <Skeleton className="h-72 w-full" />; 7 - }
-27
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/general/page.tsx
··· 1 - import { Separator } from "@openstatus/ui"; 2 - 3 - import { WorkspaceForm } from "@/components/forms/workspace-form"; 4 - import { api } from "@/trpc/server"; 5 - import { CopyToClipboardButton } from "./_components/copy-to-clipboard-button"; 6 - 7 - export default async function GeneralPage() { 8 - const data = await api.workspace.getWorkspace.query(); 9 - 10 - return ( 11 - <div className="flex flex-col gap-8"> 12 - <WorkspaceForm defaultValues={{ name: data.name ?? "" }} /> 13 - <Separator /> 14 - <div className="flex flex-col gap-2"> 15 - <p>Workspace Slug</p> 16 - <p className="text-muted-foreground text-sm"> 17 - The unique identifier for your workspace. 18 - </p> 19 - <div> 20 - <CopyToClipboardButton variant="outline" size="sm"> 21 - {data.slug} 22 - </CopyToClipboardButton> 23 - </div> 24 - </div> 25 - </div> 26 - ); 27 - }
-13
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/layout.tsx
··· 1 - import AppPageWithSidebarLayout from "@/components/layout/app-page-with-sidebar-layout"; 2 - 3 - export default function SettingsLayout({ 4 - children, 5 - }: { 6 - children: React.ReactNode; 7 - }) { 8 - return ( 9 - <AppPageWithSidebarLayout id="settings"> 10 - {children} 11 - </AppPageWithSidebarLayout> 12 - ); 13 - }
-8
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - export default async function SettingsPage(props: { 4 - params: Promise<{ workspaceSlug: string }>; 5 - }) { 6 - const params = await props.params; 7 - return redirect(`/app/${params.workspaceSlug}/settings/general`); 8 - }
-27
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/team/_components/info-banner.tsx
··· 1 - "use client"; 2 - 3 - import { Info } from "lucide-react"; 4 - import Link from "next/link"; 5 - import { useParams } from "next/navigation"; 6 - 7 - import { Alert, AlertDescription, AlertTitle } from "@openstatus/ui"; 8 - 9 - export function InfoBanner() { 10 - const params = useParams<{ workspaceSlug: string }>(); 11 - return ( 12 - <Alert className="bg-muted/50"> 13 - <Info className="h-4 w-4" /> 14 - <AlertTitle>You&apos;re workspace name is empty</AlertTitle> 15 - <AlertDescription> 16 - To inform your team about the workspace name, please set it in the{" "} 17 - <Link 18 - href={`/app/${params.workspaceSlug}/settings/general`} 19 - className="inline-flex items-center font-medium text-foreground underline underline-offset-4 hover:no-underline" 20 - > 21 - general 22 - </Link>{" "} 23 - settings. 24 - </AlertDescription> 25 - </Alert> 26 - ); 27 - }
-112
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/team/_components/invite-button.tsx
··· 1 - "use client"; 2 - 3 - import { zodResolver } from "@hookform/resolvers/zod"; 4 - import { useRouter } from "next/navigation"; 5 - import { useState, useTransition } from "react"; 6 - import { useForm } from "react-hook-form"; 7 - import * as z from "zod"; 8 - 9 - import { 10 - Button, 11 - Dialog, 12 - DialogContent, 13 - DialogDescription, 14 - DialogHeader, 15 - DialogTitle, 16 - DialogTrigger, 17 - Form, 18 - FormControl, 19 - FormDescription, 20 - FormField, 21 - FormItem, 22 - FormLabel, 23 - FormMessage, 24 - Input, 25 - } from "@openstatus/ui"; 26 - 27 - import { LoadingAnimation } from "@/components/loading-animation"; 28 - import { toastAction } from "@/lib/toast"; 29 - import { api } from "@/trpc/client"; 30 - 31 - const schema = z.object({ 32 - email: z.string().email(), 33 - }); 34 - 35 - type Schema = z.infer<typeof schema>; 36 - 37 - export function InviteButton({ 38 - defaultValues, 39 - disabled, 40 - }: { 41 - defaultValues?: Schema; 42 - disabled?: boolean; 43 - }) { 44 - const [open, setOpen] = useState(false); 45 - const form = useForm<Schema>({ 46 - resolver: zodResolver(schema), 47 - defaultValues, 48 - }); 49 - const router = useRouter(); 50 - const [isPending, startTransition] = useTransition(); 51 - 52 - async function onSubmit(data: Schema) { 53 - startTransition(async () => { 54 - try { 55 - const invitation = await api.invitation.create.mutate(data); 56 - await api.emailRouter.sendTeamInvitation.mutate({ id: invitation.id }); 57 - toastAction("saved"); 58 - router.refresh(); 59 - } catch { 60 - toastAction("error"); 61 - } finally { 62 - setOpen(false); 63 - } 64 - }); 65 - } 66 - 67 - return ( 68 - <Dialog open={open} onOpenChange={setOpen}> 69 - <DialogTrigger asChild> 70 - <Button onClick={() => setOpen((v) => !v)} disabled={disabled}> 71 - Invite Member 72 - </Button> 73 - </DialogTrigger> 74 - <DialogContent> 75 - <DialogHeader> 76 - <DialogTitle>Invite your team members!</DialogTitle> 77 - <DialogDescription> 78 - They will receive an email invite to join your workspace. 79 - </DialogDescription> 80 - </DialogHeader> 81 - <Form {...form}> 82 - <form 83 - onSubmit={form.handleSubmit(onSubmit)} 84 - className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 85 - > 86 - <FormField 87 - control={form.control} 88 - name="email" 89 - render={({ field }) => ( 90 - <FormItem className="sm:col-span-5"> 91 - <FormLabel>Email</FormLabel> 92 - <FormControl> 93 - <Input {...field} /> 94 - </FormControl> 95 - <FormDescription> 96 - We will send an invite to this email address. 97 - </FormDescription> 98 - <FormMessage /> 99 - </FormItem> 100 - )} 101 - /> 102 - <div className="sm:col-span-full"> 103 - <Button className="w-full sm:w-auto" size="lg"> 104 - {!isPending ? "Confirm" : <LoadingAnimation />} 105 - </Button> 106 - </div> 107 - </form> 108 - </Form> 109 - </DialogContent> 110 - </Dialog> 111 - ); 112 - }
-13
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/team/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import { getPageBySegment } from "@/config/pages"; 3 - 4 - const page = getPageBySegment(["settings", "team"]); 5 - 6 - export default function Layout({ children }: { children: React.ReactNode }) { 7 - return ( 8 - <> 9 - <Header title={page?.title} description={page?.description} /> 10 - {children} 11 - </> 12 - ); 13 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/team/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - // TODO: can be improved... 4 - 5 - export default function Loading() { 6 - return <Skeleton className="h-72 w-full" />; 7 - }
-34
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/team/page.tsx
··· 1 - import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 2 - import { columns as invitationColumns } from "@/components/data-table/invitation/columns"; 3 - import { DataTable as InvitationDataTable } from "@/components/data-table/invitation/data-table"; 4 - import { columns as userColumns } from "@/components/data-table/user/columns"; 5 - import { DataTable as UserDataTable } from "@/components/data-table/user/data-table"; 6 - import { api } from "@/trpc/server"; 7 - import { InfoBanner } from "./_components/info-banner"; 8 - import { InviteButton } from "./_components/invite-button"; 9 - 10 - export default async function TeamPage() { 11 - const workspace = await api.workspace.getWorkspace.query(); 12 - const invitations = await api.invitation.getWorkspaceOpenInvitations.query(); 13 - const users = await api.workspace.getWorkspaceUsers.query(); 14 - 15 - const isFreePlan = workspace.plan === "free"; 16 - 17 - return ( 18 - <div className="flex flex-col gap-4"> 19 - {isFreePlan ? <ProFeatureAlert feature="Team members" /> : null} 20 - {!isFreePlan && !workspace.name ? <InfoBanner /> : null} 21 - {/* TODO: only show if isAdmin */} 22 - <div className="flex justify-end"> 23 - <InviteButton disabled={isFreePlan} /> 24 - </div> 25 - <UserDataTable 26 - data={users.map(({ role, user }) => ({ role, ...user }))} 27 - columns={userColumns} 28 - /> 29 - {invitations.length > 0 ? ( 30 - <InvitationDataTable data={invitations} columns={invitationColumns} /> 31 - ) : null} 32 - </div> 33 - ); 34 - }
-13
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/user/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import { getPageBySegment } from "@/config/pages"; 3 - 4 - const page = getPageBySegment(["settings", "user"]); 5 - 6 - export default function Layout({ children }: { children: React.ReactNode }) { 7 - return ( 8 - <> 9 - <Header title={page?.title} description={page?.description} /> 10 - {children} 11 - </> 12 - ); 13 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/user/loading.tsx
··· 1 - import { Skeleton } from "@openstatus/ui"; 2 - 3 - // TODO: can be improved... 4 - 5 - export default function Loading() { 6 - return <Skeleton className="h-72 w-full" />; 7 - }
-48
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/settings/user/page.tsx
··· 1 - "use client"; 2 - 3 - import { useSession } from "next-auth/react"; 4 - 5 - import { Input, Label } from "@openstatus/ui"; 6 - 7 - import Loading from "./loading"; 8 - 9 - export default function UserPage() { 10 - const session = useSession(); 11 - 12 - if (!session.data?.user) { 13 - return <Loading />; 14 - } 15 - 16 - return ( 17 - <div className="flex flex-col gap-8"> 18 - <div className="grid max-w-sm gap-3"> 19 - <div className="grid w-full items-center gap-1.5"> 20 - <Label htmlFor="fullname">Full name</Label> 21 - <Input id="fullname" value={`${session.data.user?.name}`} disabled /> 22 - </div> 23 - <div className="grid w-full items-center gap-1.5"> 24 - <Label htmlFor="email">Email</Label> 25 - <Input 26 - id="email" 27 - type="email" 28 - value={`${session.data.user?.email}`} 29 - disabled 30 - /> 31 - </div> 32 - {/* <div className="flex flex-wrap items-end gap-2"> 33 - <div className="grid items-center gap-1.5"> 34 - <Label htmlFor="avatar">Image</Label> 35 - <Input id="avatar" type="file" className="w-56" disabled /> 36 - </div> 37 - <Avatar className="h-10 w-10"> 38 - <AvatarImage 39 - src={session.data.user?.photoUrl || undefined} 40 - alt={`${session.data.user?.name}`} 41 - /> 42 - <AvatarFallback></AvatarFallback> 43 - </Avatar> 44 - </div> */} 45 - </div> 46 - </div> 47 - ); 48 - }
-53
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/(overview)/_components/status-report-button.tsx
··· 1 - import { cn } from "@/lib/utils"; 2 - import type { Page } from "@openstatus/db/src/schema"; 3 - import { 4 - Alert, 5 - AlertDescription, 6 - AlertTitle, 7 - } from "@openstatus/ui/src/components/alert"; 8 - import { buttonVariants } from "@openstatus/ui/src/components/button"; 9 - import { 10 - DropdownMenu, 11 - DropdownMenuContent, 12 - DropdownMenuItem, 13 - DropdownMenuLabel, 14 - DropdownMenuSeparator, 15 - DropdownMenuTrigger, 16 - } from "@openstatus/ui/src/components/dropdown-menu"; 17 - import { ChevronDown, Megaphone } from "lucide-react"; 18 - import Link from "next/link"; 19 - 20 - export function StatusReportButton({ pages }: { pages: Page[] }) { 21 - return ( 22 - <Alert className="max-w-xl"> 23 - <AlertTitle>Status Reports</AlertTitle> 24 - <AlertDescription> 25 - Start a new report. If you want to update your users about a current 26 - report, please hover the <em>Last Report</em> column and click on{" "} 27 - <em>Go to report</em>. 28 - </AlertDescription> 29 - <DropdownMenu> 30 - <DropdownMenuTrigger 31 - className={cn( 32 - buttonVariants({ size: "sm", variant: "secondary" }), 33 - "mt-2", 34 - )} 35 - > 36 - <Megaphone className="mr-1 h-4 w-4 pb-0.5" /> 37 - New Status Report 38 - <span className="mx-2 h-8 w-px bg-background" /> 39 - <ChevronDown className="h-4 w-4" /> 40 - </DropdownMenuTrigger> 41 - <DropdownMenuContent align="end"> 42 - <DropdownMenuLabel>Select Page</DropdownMenuLabel> 43 - <DropdownMenuSeparator /> 44 - {pages.map((page) => ( 45 - <Link key={page.id} href={`./status-pages/${page.id}/reports/new`}> 46 - <DropdownMenuItem>{page.title}</DropdownMenuItem> 47 - </Link> 48 - ))} 49 - </DropdownMenuContent> 50 - </DropdownMenu> 51 - </Alert> 52 - ); 53 - }
-30
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/(overview)/layout.tsx
··· 1 - import { ButtonWithDisableTooltip } from "@openstatus/ui"; 2 - import Link from "next/link"; 3 - import type { ReactNode } from "react"; 4 - 5 - import { Header } from "@/components/dashboard/header"; 6 - import AppPageLayout from "@/components/layout/app-page-layout"; 7 - import { api } from "@/trpc/server"; 8 - 9 - export default async function Layout({ children }: { children: ReactNode }) { 10 - const isLimitReached = await api.page.isPageLimitReached.query(); 11 - 12 - return ( 13 - <AppPageLayout> 14 - <Header 15 - title="Status Pages" 16 - description="Overview of all your pages." 17 - actions={ 18 - <ButtonWithDisableTooltip 19 - tooltip="You reached the limits" 20 - asChild={!isLimitReached} 21 - disabled={isLimitReached} 22 - > 23 - <Link href="./status-pages/new">Create</Link> 24 - </ButtonWithDisableTooltip> 25 - } 26 - /> 27 - {children} 28 - </AppPageLayout> 29 - ); 30 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/(overview)/loading.tsx
··· 1 - import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 2 - 3 - export default function Loading() { 4 - return <DataTableSkeleton />; 5 - }
-38
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/(overview)/page.tsx
··· 1 - import Link from "next/link"; 2 - 3 - import { Button } from "@openstatus/ui/src/components/button"; 4 - 5 - import { EmptyState } from "@/components/dashboard/empty-state"; 6 - import { Limit } from "@/components/dashboard/limit"; 7 - import { columns } from "@/components/data-table/status-page/columns"; 8 - import { DataTable } from "@/components/data-table/status-page/data-table"; 9 - import { api } from "@/trpc/server"; 10 - import { StatusReportButton } from "./_components/status-report-button"; 11 - 12 - export default async function MonitorPage() { 13 - const pages = await api.page.getPagesByWorkspace.query(); 14 - 15 - if (pages?.length === 0) { 16 - return ( 17 - <EmptyState 18 - icon="panel-top" 19 - title="No pages" 20 - description="Create your first page" 21 - action={ 22 - <Button asChild> 23 - <Link href="./status-pages/new">Create</Link> 24 - </Button> 25 - } 26 - /> 27 - ); 28 - } 29 - const isLimitReached = await api.page.isPageLimitReached.query(); 30 - 31 - return ( 32 - <> 33 - <DataTable columns={columns} data={pages} /> 34 - {isLimitReached ? <Limit /> : null} 35 - <StatusReportButton pages={pages} /> 36 - </> 37 - ); 38 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/domain/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 - 3 - export default function Loading() { 4 - return <SkeletonForm />; 5 - }
-29
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/domain/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 4 - import { CustomDomainForm } from "@/components/forms/custom-domain-form"; 5 - import { api } from "@/trpc/server"; 6 - 7 - export default async function CustomDomainPage(props: { 8 - params: Promise<{ workspaceSlug: string; id: string }>; 9 - }) { 10 - const params = await props.params; 11 - const id = Number(params.id); 12 - const page = await api.page.getPageById.query({ id }); 13 - const workspace = await api.workspace.getWorkspace.query(); 14 - 15 - const isValid = workspace.limits["custom-domain"]; 16 - 17 - if (!page) return notFound(); 18 - 19 - if (!isValid) return <ProFeatureAlert feature="Custom domains" />; 20 - 21 - return ( 22 - <CustomDomainForm 23 - defaultValues={{ 24 - customDomain: page.customDomain, 25 - id: page.id, 26 - }} 27 - /> 28 - ); 29 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/edit/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 - 3 - export default function Loading() { 4 - return <SkeletonForm />; 5 - }
-39
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/edit/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { StatusPageForm } from "@/components/forms/status-page/form"; 4 - import { api } from "@/trpc/server"; 5 - import { searchParamsCache } from "./search-params"; 6 - 7 - export default async function EditPage(props: { 8 - params: Promise<{ workspaceSlug: string; id: string }>; 9 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 10 - }) { 11 - const searchParams = await props.searchParams; 12 - const params = await props.params; 13 - const id = Number(params.id); 14 - const page = await api.page.getPageById.query({ id }); 15 - const workspace = await api.workspace.getWorkspace.query(); 16 - const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 17 - 18 - if (!page) { 19 - return notFound(); 20 - } 21 - 22 - const { section } = searchParamsCache.parse(searchParams); 23 - 24 - return ( 25 - <StatusPageForm 26 - allMonitors={allMonitors} 27 - defaultValues={{ 28 - ...page, 29 - monitors: page.monitorsToPages.map(({ monitorId, order }) => ({ 30 - monitorId, 31 - order, 32 - })), 33 - }} 34 - defaultSection={section} 35 - plan={workspace.plan} 36 - workspaceSlug={params.workspaceSlug} 37 - /> 38 - ); 39 - }
-7
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/edit/search-params.ts
··· 1 - import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 - 3 - export const searchParamsParsers = { 4 - section: parseAsString.withDefault("monitors"), 5 - }; 6 - 7 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-53
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/layout.tsx
··· 1 - import Link from "next/link"; 2 - import { notFound } from "next/navigation"; 3 - 4 - import { Button } from "@openstatus/ui/src/components/button"; 5 - 6 - import { getBaseUrl } from "@/app/status-page/[domain]/utils"; 7 - import { Header } from "@/components/dashboard/header"; 8 - import AppPageWithSidebarLayout from "@/components/layout/app-page-with-sidebar-layout"; 9 - import { CopyButton } from "@/components/layout/header/copy-button"; 10 - import { api } from "@/trpc/server"; 11 - 12 - export default async function Layout(props: { 13 - children: React.ReactNode; 14 - params: Promise<{ workspaceSlug: string; id: string }>; 15 - }) { 16 - const params = await props.params; 17 - 18 - const { children } = props; 19 - 20 - const id = params.id; 21 - 22 - const page = await api.page.getPageById.query({ 23 - id: Number(id), 24 - }); 25 - 26 - if (!page) { 27 - return notFound(); 28 - } 29 - 30 - return ( 31 - <AppPageWithSidebarLayout id="status-pages"> 32 - <Header 33 - title={page.title} 34 - description={page.description} 35 - actions={[ 36 - <CopyButton key="copy" id={page.id} />, 37 - <Button key="visit" variant="outline" asChild> 38 - <Link 39 - target="_blank" 40 - href={getBaseUrl({ 41 - slug: page.slug, 42 - customDomain: page.customDomain, 43 - })} 44 - > 45 - Visit 46 - </Link> 47 - </Button>, 48 - ]} 49 - /> 50 - {children} 51 - </AppPageWithSidebarLayout> 52 - ); 53 - }
-40
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/maintenances/(overview)/page.tsx
··· 1 - import { EmptyState } from "@/components/dashboard/empty-state"; 2 - import { columns } from "@/components/data-table/maintenance/columns"; 3 - import { DataTable } from "@/components/data-table/maintenance/data-table"; 4 - import { api } from "@/trpc/server"; 5 - import { Button } from "@openstatus/ui/src/components/button"; 6 - import Link from "next/link"; 7 - 8 - export default async function MaintenancePage(props: { 9 - params: Promise<{ workspaceSlug: string; id: string }>; 10 - }) { 11 - const params = await props.params; 12 - const maintenances = await api.maintenance.getByPage.query({ 13 - id: Number(params.id), 14 - }); 15 - 16 - if (maintenances?.length === 0) 17 - return ( 18 - <EmptyState 19 - icon="hammer" 20 - title="No maintenances" 21 - description="Add a maintenance to your status page." 22 - action={ 23 - <Button asChild> 24 - <Link href="./maintenances/new">Create a maintenance</Link> 25 - </Button> 26 - } 27 - /> 28 - ); 29 - 30 - return ( 31 - <div className="flex flex-col gap-4"> 32 - <div> 33 - <Button size="sm" asChild> 34 - <Link href="./maintenances/new">Create a maintenance</Link> 35 - </Button> 36 - </div> 37 - <DataTable data={maintenances} columns={columns} /> 38 - </div> 39 - ); 40 - }
-23
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/edit/page.tsx
··· 1 - import { MaintenanceForm } from "@/components/forms/maintenance/form"; 2 - import { api } from "@/trpc/server"; 3 - 4 - export default async function MaintenancePage(props: { 5 - params: Promise<{ workspaceSlug: string; id: string; maintenanceId: string }>; 6 - }) { 7 - const params = await props.params; 8 - const monitors = await api.monitor.getMonitorsByPageId.query({ 9 - id: Number(params.id), 10 - }); 11 - const maintenance = await api.maintenance.getById.query({ 12 - id: Number(params.maintenanceId), 13 - }); 14 - 15 - return ( 16 - <MaintenanceForm 17 - defaultValues={maintenance} 18 - pageId={Number(params.id)} 19 - defaultSection="connect" 20 - monitors={monitors} 21 - /> 22 - ); 23 - }
-20
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/maintenances/new/page.tsx
··· 1 - import { MaintenanceForm } from "@/components/forms/maintenance/form"; 2 - import { api } from "@/trpc/server"; 3 - 4 - export default async function MaintenancePage(props: { 5 - params: Promise<{ workspaceSlug: string; id: string }>; 6 - }) { 7 - const params = await props.params; 8 - const monitors = await api.monitor.getMonitorsByPageId.query({ 9 - id: Number(params.id), 10 - }); 11 - 12 - return ( 13 - <MaintenanceForm 14 - nextUrl="./" // back to the overview page 15 - defaultSection="connect" 16 - pageId={Number(params.id)} 17 - monitors={monitors} 18 - /> 19 - ); 20 - }
-8
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - export default async function Page(props: { 4 - params: Promise<{ workspaceSlug: string; id: string }>; 5 - }) { 6 - const params = await props.params; 7 - return redirect(`./${params.id}/edit`); 8 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/(overview)/loading.tsx
··· 1 - import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 2 - 3 - export default function Loading() { 4 - return <DataTableSkeleton />; 5 - }
-40
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/(overview)/page.tsx
··· 1 - import Link from "next/link"; 2 - 3 - import { Button } from "@openstatus/ui/src/components/button"; 4 - 5 - import { EmptyState } from "@/components/dashboard/empty-state"; 6 - import { columns } from "@/components/data-table/status-report/columns"; 7 - import { DataTable } from "@/components/data-table/status-report/data-table"; 8 - import { api } from "@/trpc/server"; 9 - 10 - export default async function MonitorPage(props: { 11 - params: Promise<{ id: string }>; 12 - }) { 13 - const params = await props.params; 14 - const reports = await api.statusReport.getStatusReportByPageId.query({ 15 - id: Number.parseInt(params.id), 16 - }); 17 - 18 - if (reports?.length === 0) 19 - return ( 20 - <EmptyState 21 - icon="siren" 22 - title="No status reports" 23 - description="Create your first status report" 24 - action={ 25 - <Button asChild> 26 - <Link href="./reports/new">Create</Link> 27 - </Button> 28 - } 29 - /> 30 - ); 31 - 32 - return ( 33 - <div className="space-y-3"> 34 - <Button size="sm" asChild> 35 - <Link href="./reports/new">Create Status Report</Link> 36 - </Button> 37 - <DataTable columns={columns} data={reports} /> 38 - </div> 39 - ); 40 - }
-42
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/[reportId]/_components/status-update-button.tsx
··· 1 - "use client"; 2 - 3 - import { useState } from "react"; 4 - 5 - import { 6 - Button, 7 - Dialog, 8 - DialogContent, 9 - DialogDescription, 10 - DialogHeader, 11 - DialogTitle, 12 - DialogTrigger, 13 - } from "@openstatus/ui"; 14 - 15 - import { StatusReportUpdateForm } from "@/components/forms/status-report-update/form"; 16 - 17 - export function StatusUpdateButton({ 18 - statusReportId, 19 - }: { 20 - statusReportId: number; 21 - }) { 22 - const [open, setOpen] = useState(false); 23 - return ( 24 - <Dialog open={open} onOpenChange={setOpen}> 25 - <DialogTrigger asChild> 26 - <Button>Status Update</Button> 27 - </DialogTrigger> 28 - <DialogContent className="max-h-screen overflow-y-scroll sm:max-w-[650px]"> 29 - <DialogHeader> 30 - <DialogTitle>New Status Report</DialogTitle> 31 - <DialogDescription> 32 - Provide a status update and add it to the report history. 33 - </DialogDescription> 34 - </DialogHeader> 35 - <StatusReportUpdateForm 36 - statusReportId={statusReportId} 37 - onSubmit={() => setOpen(false)} 38 - /> 39 - </DialogContent> 40 - </Dialog> 41 - ); 42 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/[reportId]/edit/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 - 3 - export default function Loading() { 4 - return <SkeletonForm />; 5 - }
-34
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/[reportId]/edit/page.tsx
··· 1 - import { StatusReportForm } from "@/components/forms/status-report/form"; 2 - import { api } from "@/trpc/server"; 3 - 4 - export default async function EditPage(props: { 5 - params: Promise<{ workspaceSlug: string; id: string; reportId: string }>; 6 - }) { 7 - const params = await props.params; 8 - const statusUpdate = await api.statusReport.getStatusReportById.query({ 9 - id: Number.parseInt(params.reportId), 10 - pageId: Number.parseInt(params.id), 11 - }); 12 - 13 - const monitors = await api.monitor.getMonitorsByWorkspace.query(); 14 - 15 - return ( 16 - <StatusReportForm 17 - monitors={monitors} 18 - defaultValues={ 19 - // TODO: we should move the mapping to the trpc layer 20 - // so we don't have to do this in the UI 21 - // it should be something like defaultValues={statusReport} 22 - { 23 - ...statusUpdate, 24 - monitors: statusUpdate?.monitorsToStatusReports.map( 25 - ({ monitorId }) => monitorId, 26 - ), 27 - message: "", 28 - } 29 - } 30 - pageId={Number.parseInt(params.id)} 31 - defaultSection="connect" 32 - /> 33 - ); 34 - }
-80
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/[reportId]/overview/_components/header.tsx
··· 1 - "use client"; 2 - 3 - import { StatusReportUpdateForm } from "@/components/forms/status-report-update/form"; 4 - import { StatusBadge } from "@/components/status-update/status-badge"; 5 - import { formatDate } from "@/lib/utils"; 6 - import type { 7 - Monitor, 8 - StatusReport, 9 - StatusReportUpdate, 10 - } from "@openstatus/db/src/schema"; 11 - import { 12 - Badge, 13 - Button, 14 - Dialog, 15 - DialogContent, 16 - DialogDescription, 17 - DialogHeader, 18 - DialogTitle, 19 - DialogTrigger, 20 - Separator, 21 - } from "@openstatus/ui"; 22 - import { useState } from "react"; 23 - 24 - export function Header({ 25 - report, 26 - monitors, 27 - }: { 28 - report: StatusReport & { statusReportUpdates: StatusReportUpdate[] }; 29 - monitors?: Pick<Monitor, "name" | "id">[]; 30 - }) { 31 - const [open, setOpen] = useState(false); 32 - 33 - const firstUpdate = report.statusReportUpdates?.[0]; 34 - const _lastUpdate = 35 - report.statusReportUpdates?.[report.statusReportUpdates?.length - 1]; 36 - 37 - return ( 38 - <div className="space-y-3"> 39 - <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> 40 - <div className="space-y-2"> 41 - <h3 className="font-cal text-lg">{report.title}</h3> 42 - <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm"> 43 - <span className="font-mono"> 44 - {firstUpdate?.date 45 - ? formatDate(firstUpdate?.date) 46 - : "Missing date"} 47 - </span> 48 - <span className="text-muted-foreground/50 text-xs">•</span> 49 - <StatusBadge status={report.status} /> 50 - <span className="text-muted-foreground/50 text-xs">•</span> 51 - {monitors?.map(({ name, id }) => ( 52 - <Badge key={id} variant="outline"> 53 - {name} 54 - </Badge> 55 - ))} 56 - </div> 57 - </div> 58 - 59 - <Dialog open={open} onOpenChange={setOpen}> 60 - <DialogTrigger asChild> 61 - <Button size="sm">Create Status Update Report</Button> 62 - </DialogTrigger> 63 - <DialogContent className="max-h-screen overflow-y-scroll sm:max-w-[650px]"> 64 - <DialogHeader> 65 - <DialogTitle>Edit Status Report</DialogTitle> 66 - <DialogDescription> 67 - Update your status report with new information. 68 - </DialogDescription> 69 - </DialogHeader> 70 - <StatusReportUpdateForm 71 - statusReportId={report.id} 72 - onSubmit={() => setOpen(false)} 73 - /> 74 - </DialogContent> 75 - </Dialog> 76 - </div> 77 - <Separator /> 78 - </div> 79 - ); 80 - }
-26
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/[reportId]/overview/loading.tsx
··· 1 - import { Separator, Skeleton } from "@openstatus/ui"; 2 - 3 - export default function Loading() { 4 - return ( 5 - <div className="col-span-full flex flex-col gap-6"> 6 - <div className="space-y-3"> 7 - <div className="flex items-end justify-between"> 8 - <div className="flex-1 space-y-2"> 9 - <Skeleton className="h-7 w-full max-w-[200px]" /> 10 - <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm"> 11 - <Skeleton className="h-5 w-full max-w-[100px]" /> 12 - <span className="text-muted-foreground/50 text-xs">•</span> 13 - <Skeleton className="h-[22px] w-24 rounded-full" /> 14 - <span className="text-muted-foreground/50 text-xs">•</span> 15 - <Skeleton className="h-[22px] w-16 rounded-full" /> 16 - </div> 17 - </div> 18 - <Skeleton className="h-8 w-48" /> 19 - </div> 20 - <Separator /> 21 - </div> 22 - <Skeleton className="h-48 w-full" /> 23 - <Skeleton className="h-48 w-full" /> 24 - </div> 25 - ); 26 - }
-35
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/[reportId]/overview/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { EmptyState } from "@/components/dashboard/empty-state"; 4 - import { Events } from "@/components/status-update/events"; 5 - import { api } from "@/trpc/server"; 6 - import { Header } from "./_components/header"; 7 - 8 - export default async function OverviewPage(props: { 9 - params: Promise<{ workspaceSlug: string; id: string; reportId: string }>; 10 - }) { 11 - const params = await props.params; 12 - const report = await api.statusReport.getStatusReportById.query({ 13 - id: Number.parseInt(params.reportId), 14 - pageId: Number.parseInt(params.id), 15 - }); 16 - 17 - if (!report) return notFound(); 18 - 19 - const monitors = report.monitorsToStatusReports.map(({ monitor }) => monitor); 20 - 21 - return ( 22 - <> 23 - <Header report={report} monitors={monitors} /> 24 - {report.statusReportUpdates.length > 0 ? ( 25 - <Events statusReportUpdates={report.statusReportUpdates} editable /> 26 - ) : ( 27 - <EmptyState 28 - icon="megaphone" 29 - title="No status report updates" 30 - description="Create your first update" 31 - /> 32 - )} 33 - </> 34 - ); 35 - }
-8
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/[reportId]/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - export default async function Page(props: { 4 - params: Promise<{ workspaceSlug: string; reportId: string }>; 5 - }) { 6 - const params = await props.params; 7 - return redirect(`./${params.reportId}/overview`); 8 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/new/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 - 3 - export default function Loading() { 4 - return <SkeletonForm />; 5 - }
-18
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/reports/new/page.tsx
··· 1 - import { StatusReportForm } from "@/components/forms/status-report/form"; 2 - import { api } from "@/trpc/server"; 3 - 4 - export default async function NewPage(props: { 5 - params: Promise<{ id: string; reportId: string }>; 6 - }) { 7 - const params = await props.params; 8 - const monitors = await api.monitor.getMonitorsByWorkspace.query(); 9 - 10 - return ( 11 - <StatusReportForm 12 - monitors={monitors} 13 - nextUrl={"./"} 14 - defaultSection="update-message" 15 - pageId={Number.parseInt(params.id)} 16 - /> 17 - ); 18 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/subscribers/loading.tsx
··· 1 - import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; 2 - 3 - export default function Loading() { 4 - return <DataTableSkeleton />; 5 - }
-26
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/[id]/subscribers/page.tsx
··· 1 - import { notFound } from "next/navigation"; 2 - 3 - import { ProFeatureAlert } from "@/components/billing/pro-feature-alert"; 4 - import { columns } from "@/components/data-table/page-subscriber/columns"; 5 - import { DataTable } from "@/components/data-table/page-subscriber/data-table"; 6 - import { api } from "@/trpc/server"; 7 - 8 - export default async function CustomDomainPage(props: { 9 - params: Promise<{ workspaceSlug: string; id: string }>; 10 - }) { 11 - const params = await props.params; 12 - const id = Number(params.id); 13 - const page = await api.page.getPageById.query({ id }); 14 - const workspace = await api.workspace.getWorkspace.query(); 15 - 16 - if (!page) return notFound(); 17 - 18 - const isValid = workspace.limits["status-subscribers"]; 19 - if (!isValid) return <ProFeatureAlert feature={"Status page subscribers"} />; 20 - 21 - const data = await api.pageSubscriber.getPageSubscribersByPageId.query({ 22 - id, 23 - }); 24 - 25 - return <DataTable data={data} columns={columns} />; 26 - }
-15
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/new/layout.tsx
··· 1 - import { Header } from "@/components/dashboard/header"; 2 - import AppPageLayout from "@/components/layout/app-page-layout"; 3 - 4 - export default async function Layout({ 5 - children, 6 - }: { 7 - children: React.ReactNode; 8 - }) { 9 - return ( 10 - <AppPageLayout> 11 - <Header title="Pages" description="Create your page." /> 12 - {children} 13 - </AppPageLayout> 14 - ); 15 - }
-5
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/new/loading.tsx
··· 1 - import { SkeletonForm } from "@/components/forms/skeleton-form"; 2 - 3 - export default function Loading() { 4 - return <SkeletonForm />; 5 - }
-25
apps/web/src/app/(pages)/app/[workspaceSlug]/(dashboard)/status-pages/new/page.tsx
··· 1 - import { redirect } from "next/navigation"; 2 - 3 - import { StatusPageForm } from "@/components/forms/status-page/form"; 4 - import { api } from "@/trpc/server"; 5 - 6 - export default async function Page(props: { 7 - params: Promise<{ workspaceSlug: string }>; 8 - }) { 9 - const params = await props.params; 10 - const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 11 - const isLimitReached = await api.page.isPageLimitReached.query(); 12 - const workspace = await api.workspace.getWorkspace.query(); 13 - 14 - if (isLimitReached) return redirect("./"); 15 - 16 - return ( 17 - <StatusPageForm 18 - allMonitors={allMonitors} // FIXME: rename to just 'monitors' 19 - nextUrl="./" // back to the overview page 20 - defaultSection="monitors" 21 - workspaceSlug={params.workspaceSlug} 22 - plan={workspace.plan} 23 - /> 24 - ); 25 - }
-84
apps/web/src/app/(pages)/app/[workspaceSlug]/onboarding/_components/description.tsx
··· 1 - import { Icons } from "@/components/icons"; 2 - import { cn } from "@/lib/utils"; 3 - 4 - const steps = ["monitor", "status-page"] as const; 5 - // potentially move to `config/onboarding` 6 - const onboardingConfig = { 7 - monitor: { 8 - icon: "activity", 9 - name: "Monitor", 10 - description: [ 11 - { 12 - title: "What is a monitor?", 13 - text: "A monitor is a website or api endpoint that you are going to ping on a regular basis.", 14 - }, 15 - { 16 - title: "How to create monitors?", 17 - text: "You can create a monitor like you are about to via our dashboard or with our API. E.g. you can create a monitor for every instance you deploy programmatically.", 18 - }, 19 - ], 20 - }, 21 - "status-page": { 22 - icon: "panel-top", 23 - name: "Status Page", 24 - description: [ 25 - { 26 - title: "How to use status pages?", 27 - text: "Add the monitors you'd like to track to a status page and inform your users if your services are down.", 28 - }, 29 - { 30 - title: "Subdomain or custom domains?", 31 - text: "Start with a unique subdomain slug and move to your own custom domains afterwards by updating the DNS settings.", 32 - }, 33 - ], 34 - }, 35 - } as const; 36 - 37 - export function Description({ 38 - step, 39 - }: { 40 - step?: keyof typeof onboardingConfig; 41 - }) { 42 - const config = step && onboardingConfig[step]; 43 - return ( 44 - <div className="flex h-full flex-col gap-6 border-border border-l pl-6 md:pl-8"> 45 - <div className="flex gap-5"> 46 - {steps.map((item, _i) => { 47 - const { icon, name } = onboardingConfig[item]; 48 - const StepIcon = Icons[icon]; 49 - const active = step === item; 50 - return ( 51 - <div key={name} className="flex items-center gap-2"> 52 - <div 53 - className={cn( 54 - "max-w-max rounded-full border border-border p-2", 55 - active && "border-accent-foreground", 56 - )} 57 - > 58 - <StepIcon className="h-4 w-4" /> 59 - </div> 60 - <p 61 - className={cn( 62 - "text-sm", 63 - active 64 - ? "font-semibold text-foreground" 65 - : "text-muted-foreground", 66 - )} 67 - > 68 - {name} 69 - </p> 70 - </div> 71 - ); 72 - })} 73 - </div> 74 - {config?.description.map(({ title, text }) => { 75 - return ( 76 - <dl key={title} className="grid gap-1"> 77 - <dt className="font-medium">{title}</dt> 78 - <dd className="text-muted-foreground">{text}</dd> 79 - </dl> 80 - ); 81 - })} 82 - </div> 83 - ); 84 - }
-28
apps/web/src/app/(pages)/app/[workspaceSlug]/onboarding/layout.tsx
··· 1 - import type { ReactNode } from "react"; 2 - 3 - import { Shell } from "@/components/dashboard/shell"; 4 - import { AppHeader } from "@/components/layout/header/app-header"; 5 - import { WorkspaceClientCookie } from "../worskpace-client-cookie"; 6 - 7 - // TODO: make the container min-h-screen and the footer below! 8 - export default async function AppLayout(props: { 9 - params: Promise<{ workspaceSlug: string }>; 10 - children: ReactNode; 11 - }) { 12 - const params = await props.params; 13 - 14 - const { children } = props; 15 - 16 - const { workspaceSlug } = params; 17 - return ( 18 - <div className="container relative mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-6 p-4"> 19 - <AppHeader /> 20 - <div className="flex w-full flex-1 gap-6 lg:gap-8"> 21 - <main className="z-10 flex w-full flex-1 flex-col items-start justify-center"> 22 - <Shell className="relative flex-1">{children}</Shell> 23 - </main> 24 - </div> 25 - <WorkspaceClientCookie {...{ workspaceSlug }} /> 26 - </div> 27 - ); 28 - }
-84
apps/web/src/app/(pages)/app/[workspaceSlug]/onboarding/page.tsx
··· 1 - import Link from "next/link"; 2 - import { redirect } from "next/navigation"; 3 - 4 - import { Button } from "@openstatus/ui/src/components/button"; 5 - 6 - import { Header } from "@/components/dashboard/header"; 7 - import { MonitorForm } from "@/components/forms/monitor/form"; 8 - import { StatusPageForm } from "@/components/forms/status-page/form"; 9 - import { api } from "@/trpc/server"; 10 - import { Description } from "./_components/description"; 11 - 12 - export default async function Onboarding(props: { 13 - params: Promise<{ workspaceSlug: string }>; 14 - }) { 15 - const params = await props.params; 16 - const { workspaceSlug } = params; 17 - 18 - const workspace = await api.workspace.getWorkspace.query(); 19 - const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 20 - const allPages = await api.page.getPagesByWorkspace.query(); 21 - const allNotifications = 22 - await api.notification.getNotificationsByWorkspace.query(); 23 - 24 - if (allMonitors.length === 0) { 25 - return ( 26 - <div className="flex h-full w-full flex-col gap-6 md:gap-8"> 27 - <Header 28 - title="Get Started" 29 - description="Create your first monitor." 30 - actions={ 31 - <Button variant="link" className="text-muted-foreground" asChild> 32 - <Link href={`/app/${workspaceSlug}/monitors`}>Skip</Link> 33 - </Button> 34 - } 35 - /> 36 - <div className="flex flex-1 flex-col gap-6 md:grid md:grid-cols-3 md:gap-8"> 37 - <div className="flex flex-col md:col-span-2"> 38 - <MonitorForm 39 - notifications={allNotifications} 40 - defaultSection="request" 41 - limits={workspace.limits} 42 - plan={workspace.plan} 43 - /> 44 - </div> 45 - <div className="hidden h-full md:col-span-1 md:block"> 46 - <Description step="monitor" /> 47 - </div> 48 - </div> 49 - </div> 50 - ); 51 - } 52 - 53 - if (allPages.length === 0) { 54 - return ( 55 - <div className="flex h-full w-full flex-col gap-6 md:gap-8"> 56 - <Header 57 - title="Get Started" 58 - description="Create your first status page." 59 - actions={ 60 - <Button variant="link" className="text-muted-foreground"> 61 - <Link href={`/app/${workspaceSlug}/monitors`}>Skip</Link> 62 - </Button> 63 - } 64 - /> 65 - <div className="flex flex-1 flex-col gap-6 md:grid md:grid-cols-3 md:gap-8"> 66 - <div className="flex flex-col md:col-span-2"> 67 - <StatusPageForm 68 - {...{ workspaceSlug, allMonitors }} 69 - nextUrl={`/app/${workspaceSlug}/status-pages`} 70 - defaultSection="monitors" 71 - plan="free" // user is on free plan by default 72 - checkAllMonitors 73 - /> 74 - </div> 75 - <div className="hidden h-full md:col-span-1 md:block"> 76 - <Description step="status-page" /> 77 - </div> 78 - </div> 79 - </div> 80 - ); 81 - } 82 - 83 - return redirect(`/app/${workspaceSlug}/monitors`); 84 - }
-22
apps/web/src/app/(pages)/app/[workspaceSlug]/worskpace-client-cookie.ts
··· 1 - "use client"; 2 - 3 - import { useEffect } from "react"; 4 - 5 - /** 6 - * ISSUE: using the `middleware` to add a server httpOnly cookie doesn't work 7 - * req.nextUrl.pathname.startsWith("/app") is not true on the server as we are using the /api/trpc endpoint 8 - * to mutate our database. For some reasons, querying the database works fine. 9 - */ 10 - 11 - export function WorkspaceClientCookie({ 12 - workspaceSlug, 13 - }: { 14 - workspaceSlug: string; 15 - }) { 16 - useEffect(() => { 17 - if (document) { 18 - document.cookie = `workspace-slug=${workspaceSlug}; path=/`; 19 - } 20 - }, [workspaceSlug]); 21 - return null; 22 - }
-16
apps/web/src/app/(pages)/app/invite/layout.tsx
··· 1 - import { Shell } from "@/components/dashboard/shell"; 2 - import { AppHeader } from "@/components/layout/header/app-header"; 3 - import type { ReactNode } from "react"; 4 - 5 - export default async function AppLayout({ children }: { children: ReactNode }) { 6 - return ( 7 - <div className="container relative mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-6 p-4 lg:p-8"> 8 - <AppHeader /> 9 - <div className="flex w-full flex-1 gap-6 lg:gap-8"> 10 - <main className="z-10 flex w-full flex-1 flex-col items-start justify-center"> 11 - <Shell className="relative flex-1">{children}</Shell> 12 - </main> 13 - </div> 14 - </div> 15 - ); 16 - }
-50
apps/web/src/app/(pages)/app/invite/page.tsx
··· 1 - import { Alert, AlertDescription, AlertTitle, Separator } from "@openstatus/ui"; 2 - 3 - import { Icons } from "@/components/icons"; 4 - import { api } from "@/trpc/server"; 5 - import { LinkCards } from "./_components/link-cards"; 6 - import { searchParamsCache } from "./search-params"; 7 - 8 - const AlertTriangle = Icons["alert-triangle"]; 9 - 10 - export default async function InvitePage(props: { 11 - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; 12 - }) { 13 - const searchParams = await props.searchParams; 14 - const { token } = searchParamsCache.parse(searchParams); 15 - const { message, data } = token 16 - ? await api.invitation.acceptInvitation.mutate({ token }) 17 - : { message: "Unavailable invitation token.", data: undefined }; 18 - 19 - const workspace = await api.workspace.getWorkspace.query(); 20 - 21 - if (!data) { 22 - return ( 23 - <div className="mx-auto flex h-full max-w-xl flex-1 flex-col items-center justify-center gap-4"> 24 - <h1 className="font-semibold text-2xl">Invitation</h1> 25 - <Alert variant="destructive"> 26 - <AlertTriangle className="h-4 w-4" /> 27 - <AlertTitle>Something went wrong</AlertTitle> 28 - <AlertDescription>{message}</AlertDescription> 29 - </Alert> 30 - <Separator className="my-4" /> 31 - <p className="text-muted-foreground">Quick Links</p> 32 - <LinkCards slug={workspace.slug} /> 33 - </div> 34 - ); 35 - } 36 - 37 - return ( 38 - <div className="mx-auto flex h-full max-w-xl flex-1 flex-col items-center justify-center gap-4"> 39 - <h1 className="font-semibold text-2xl">Invitation</h1> 40 - <Alert> 41 - <Icons.check className="h-4 w-4" /> 42 - <AlertTitle>Ready to go</AlertTitle> 43 - <AlertDescription>{message}</AlertDescription> 44 - </Alert> 45 - <Separator className="my-4" /> 46 - <p className="text-muted-foreground">Quick Links</p> 47 - <LinkCards slug={data.slug} /> 48 - </div> 49 - ); 50 - }
-7
apps/web/src/app/(pages)/app/invite/search-params.ts
··· 1 - import { createSearchParamsCache, parseAsString } from "nuqs/server"; 2 - 3 - export const searchParamsParsers = { 4 - token: parseAsString, 5 - }; 6 - 7 - export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
-22
apps/web/src/app/(pages)/app/layout.tsx
··· 1 - import { SessionProvider } from "next-auth/react"; 2 - 3 - import { Bubble } from "@/components/support/bubble"; 4 - import { auth } from "@/lib/auth"; 5 - import { IdentifyComponent } from "@openpanel/nextjs"; 6 - 7 - export default async function AuthLayout({ 8 - children, // will be a page or nested layout 9 - }: { 10 - children: React.ReactNode; 11 - }) { 12 - const session = await auth(); 13 - return ( 14 - <SessionProvider session={session}> 15 - {children} 16 - <Bubble /> 17 - {session?.user?.id && ( 18 - <IdentifyComponent profileId={`usr_${session?.user?.id}`} /> 19 - )} 20 - </SessionProvider> 21 - ); 22 - }
-15
apps/web/src/app/(pages)/app/onboarding/page.tsx
··· 1 - import { auth } from "@/lib/auth"; 2 - import { api } from "@/trpc/server"; 3 - import { redirect } from "next/navigation"; 4 - 5 - export default async function OnboardingPage() { 6 - const session = await auth(); 7 - 8 - if (!session) redirect("/app/login"); 9 - 10 - const workspace = await api.workspace.getWorkspace.query(); 11 - 12 - if (!workspace) redirect("/app/login"); 13 - 14 - return redirect(`/app/${workspace.slug}/onboarding`); 15 - }
-55
apps/web/src/app/(pages)/app/page.tsx
··· 1 - "use client"; 2 - 3 - import { useRouter } from "next/navigation"; 4 - 5 - import { Shell } from "@/components/dashboard/shell"; 6 - import { AppHeader } from "@/components/layout/header/app-header"; 7 - import { LoadingAnimation } from "@/components/loading-animation"; 8 - 9 - // TODO: discuss how to make that page a bit more enjoyable 10 - export default function Page() { 11 - const router = useRouter(); 12 - 13 - // waiting for the workspace to be created 14 - setTimeout(() => router.refresh(), 1000); 15 - 16 - return ( 17 - <div className="container relative mx-auto flex min-h-screen w-full flex-col items-center justify-center gap-6 p-4 lg:p-8"> 18 - <AppHeader /> 19 - <div className="flex w-full flex-1 gap-6 lg:gap-8"> 20 - <main className="z-10 flex w-full flex-1 flex-col items-start justify-center"> 21 - <Shell className="relative flex flex-1 flex-col items-center justify-center"> 22 - <div className="grid gap-4"> 23 - <div className="text-center"> 24 - <p className="mb-1 font-cal text-3xl">Creating Workspace</p> 25 - <p className="mb-5 text-muted-foreground text-xl"> 26 - Should be done in a second. 27 - </p> 28 - <p className="text-muted-foreground"> 29 - If you are stuck for longer, please contact us via{" "} 30 - <a 31 - href="https://openstatus.dev/discord" 32 - target="_blank" 33 - className="text-foreground underline underline-offset-4 hover:no-underline" 34 - rel="noreferrer" 35 - > 36 - Discord 37 - </a>{" "} 38 - or{" "} 39 - <a 40 - href="mailto:thibault@openstatus.dev" 41 - className="text-foreground underline underline-offset-4 hover:no-underline" 42 - > 43 - Mail 44 - </a> 45 - . 46 - </p> 47 - </div> 48 - <LoadingAnimation variant="inverse" size="lg" /> 49 - </div> 50 - </Shell> 51 - </main> 52 - </div> 53 - </div> 54 - ); 55 - }
-14
apps/web/src/app/api/ping/cold/route.ts
··· 1 - import { NextResponse } from "next/server"; 2 - 3 - export const dynamic = "force-dynamic"; 4 - 5 - export const maxDuration = 30; // to trick and not using the same function as the other ping route 6 - 7 - export async function GET() { 8 - return NextResponse.json({ ping: "pong" }, { status: 200 }); 9 - } 10 - 11 - export async function POST(req: Request) { 12 - const body = await req.json(); 13 - return NextResponse.json({ ping: body }, { status: 200 }); 14 - }
-13
apps/web/src/app/api/ping/edge/route.ts
··· 1 - import { NextResponse } from "next/server"; 2 - 3 - export const runtime = "edge"; 4 - export const dynamic = "force-dynamic"; 5 - 6 - export async function GET() { 7 - return NextResponse.json({ ping: "pong" }, { status: 200 }); 8 - } 9 - 10 - export async function POST(req: Request) { 11 - const body = await req.json(); 12 - return NextResponse.json({ ping: body }, { status: 200 }); 13 - }
-14
apps/web/src/app/api/ping/route.ts
··· 1 - import { NextResponse } from "next/server"; 2 - 3 - export const dynamic = "force-dynamic"; 4 - 5 - export const maxDuration = 25; // to trick and not using the same function as the other ping route 6 - 7 - export async function GET() { 8 - return NextResponse.json({ ping: "pong" }, { status: 200 }); 9 - } 10 - 11 - export async function POST(req: Request) { 12 - const body = await req.json(); 13 - return NextResponse.json({ ping: body }, { status: 200 }); 14 - }
-14
apps/web/src/app/api/ping/test/route.ts
··· 1 - import { NextResponse } from "next/server"; 2 - 3 - export const dynamic = "force-dynamic"; 4 - 5 - export const maxDuration = 31; // to trick and not using the same function as the other ping route 6 - 7 - export async function GET() { 8 - return NextResponse.json({ ping: "pong" }, { status: 200 }); 9 - } 10 - 11 - export async function POST(req: Request) { 12 - const body = await req.json(); 13 - return NextResponse.json({ ping: body }, { status: 200 }); 14 - }
-14
apps/web/src/app/api/ping/vercel/route.ts
··· 1 - import { NextResponse } from "next/server"; 2 - 3 - export const dynamic = "force-dynamic"; 4 - 5 - export const maxDuration = 42; // to trick and not using the same function as the other ping route 6 - 7 - export async function GET() { 8 - return NextResponse.json({ ping: "pong" }, { status: 200 }); 9 - } 10 - 11 - export async function POST(req: Request) { 12 - const body = await req.json(); 13 - return NextResponse.json({ ping: body }, { status: 200 }); 14 - }
-13
apps/web/src/app/api/ping/warn/route.ts
··· 1 - import { NextResponse } from "next/server"; 2 - 3 - export const dynamic = "force-dynamic"; 4 - export const maxDuration = 20; // to trick and not using the same function as the other ping route 5 - 6 - export async function GET() { 7 - return NextResponse.json({ ping: "pong" }, { status: 200 }); 8 - } 9 - 10 - export async function POST(req: Request) { 11 - const body = await req.json(); 12 - return NextResponse.json({ ping: body }, { status: 200 }); 13 - }