Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ MEDIALIT_APIKEY=
MEDIALIT_SERVER=https://api.medialit.cloud
MAX_UPLOAD_SIZE=10485760

# Optional: public status page, linked from the dashboard's user menu as
# "System status" (e.g. https://status.sendlit.app). Unset hides the link.
STATUS_PAGE_URL=

# Optional: PostHog for the API (errors/events/logs) and the web dashboard
# (identify + session recording). Unset disables telemetry. The web app reads
# these at container start — they are not compiled into the Next.js image.
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/self-hosting/meta.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"title": "Self-Hosting",
"icon": "Server",
"pages": ["amazon-ses-feedback"]
"pages": ["amazon-ses-feedback", "monitoring"]
}
40 changes: 40 additions & 0 deletions apps/docs/content/docs/self-hosting/monitoring.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
title: Monitoring and status page
description: What to watch on a SendLit deployment, and how to publish a status page.
---

Point an uptime monitor (Uptime Kuma, Upptime, Better Stack, ...) at these
endpoints. None of them needs a credential. Check every minute or so.

| Component | Check | Healthy |
| --- | --- | --- |
| API process | `GET <api>/health` | `200` |
| API dependencies (Postgres, Redis) | `GET <api>/ready` | `200`; `503` while starting, shutting down, or when Postgres or Redis is unreachable |
| Dashboard | `GET <web>/api/health` | `200` |
| MCP endpoint | `POST <api>/mcp` with body `{}` | `401` (it's up and asking for a credential) |
| Open tracking | `GET <api>/track/open?d=check` | `200` with an image |
| Unsubscribe pages | `GET <api>/unsubscribe/check` | `404` page reading "Invalid unsubscribe link" |
| API reference | `GET <api>/openapi.json` | `200` |

`/ready` is the one to alert on: it fails when the API can't reach its
database or Redis, which stops sending. Its JSON body lists each check:

```json
{"status":"ready","checks":{"started":true,"accepting_requests":true,"database":true,"redis":true}}
```

## Publish a status page

A public status page shows readers and customers whether sending, the
dashboard and links work, with past incidents. Two common options:

- **Upptime**: open source, runs on GitHub Actions and GitHub Pages, and
records incidents as GitHub issues. Create a repository from the Upptime
template, list the checks above in `.upptimerc.yml`, and point a domain such
as `status.example.com` at its GitHub Pages site with a `CNAME` record.
- **Uptime Kuma**: open source and self-hosted (one Docker container), with
built-in status pages and incident posts. Run it somewhere other than the
SendLit server, so it still works when that server is down.

Set `STATUS_PAGE_URL` on the web app to the page's address and the dashboard
shows a **System status** link in the user menu.
4 changes: 4 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ ITEMS_PER_PAGE=10
# picker. Defaults to 10MB if unset.
MAX_UPLOAD_SIZE=10485760

# Optional: public status page, linked from the dashboard's user menu as
# "System status" (e.g. https://status.sendlit.app). Unset hides the link.
STATUS_PAGE_URL=

# Optional: PostHog product analytics, identify, and session recording.
# Read at request time (not baked into `next build`) so a prebuilt OSS image
# can enable telemetry at container start. When unset, PostHog is disabled.
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BreadcrumbProvider } from "@/components/dashboard/breadcrumb-context";
import { DashboardBreadcrumb } from "@/components/dashboard/dashboard-breadcrumb";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { getStatusPageUrl } from "@/lib/config";
import { requireAuth } from "@/lib/server-auth";

export default async function DashboardLayout({
Expand All @@ -14,7 +15,7 @@ export default async function DashboardLayout({

return (
<SidebarProvider>
<AppSidebar />
<AppSidebar statusPageUrl={getStatusPageUrl()} />

<SidebarInset className="min-w-0">
<BreadcrumbProvider>
Expand Down
11 changes: 11 additions & 0 deletions apps/web/app/api/health/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from "bun:test";
import { GET } from "./route";

describe("GET /api/health", () => {
it("answers without sign-in and isn't cached", async () => {
const response = GET();
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(await response.json()).toEqual({ status: "ok", service: "sendlit-web" });
});
});
9 changes: 9 additions & 0 deletions apps/web/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** Liveness for uptime monitors: the dashboard is serving requests. Needs no
* sign-in and touches nothing else; the API has its own `/health` and
* `/ready`. */
export function GET() {
return Response.json(
{ status: "ok", service: "sendlit-web" },
{ headers: { "Cache-Control": "no-store" } },
);
}
3 changes: 2 additions & 1 deletion apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { DashboardBreadcrumb } from "@/components/dashboard/dashboard-breadcrumb
import { OverviewDashboard } from "@/components/dashboard/overview";
import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { getStatusPageUrl } from "@/lib/config";
import { requireAuth } from "@/lib/server-auth";

export default async function Home() {
await requireAuth();
return (
<SidebarProvider>
<AppSidebar />
<AppSidebar statusPageUrl={getStatusPageUrl()} />
<SidebarInset className="min-w-0">
<BreadcrumbProvider>
<header className="flex h-16 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
Expand Down
8 changes: 6 additions & 2 deletions apps/web/components/dashboard/app-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ const SECONDARY_NAV: NavMainItem[] = [
{ url: "/settings", title: "Settings", icon: Settings },
];

export function AppSidebar() {
export function AppSidebar({
statusPageUrl = null,
}: {
statusPageUrl?: string | null;
}) {
const [teams, setTeams] = useState<Team[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [organizationId, setOrganizationId] = useState<string | null>(null);
Expand Down Expand Up @@ -220,7 +224,7 @@ export function AppSidebar() {
<SidebarFooter className="p-0">
<NavMain items={SECONDARY_NAV} />
<div className="p-2">
<NavUser user={account} />
<NavUser user={account} statusPageUrl={statusPageUrl} />
</div>
</SidebarFooter>

Expand Down
11 changes: 11 additions & 0 deletions apps/web/components/dashboard/nav-user.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,15 @@ describe("NavUser", () => {
fireEvent.submit(form);
expect(mocks.resetPosthogUser).toHaveBeenCalledTimes(1);
});

it("links the status page only when the deployment has one", () => {
const { getByRole, queryByRole, rerender } = render(
<NavUser user={null} statusPageUrl="https://status.example.com" />,
);
expect(getByRole("link", { name: "System status" }).getAttribute("href")).toBe(
"https://status.example.com",
);
rerender(<NavUser user={null} />);
expect(queryByRole("link", { name: "System status" })).toBeNull();
});
});
24 changes: 22 additions & 2 deletions apps/web/components/dashboard/nav-user.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
"use client";

import { BadgeCheckIcon, ChevronsUpDownIcon, LogOutIcon } from "lucide-react";
import {
ActivityIcon,
BadgeCheckIcon,
ChevronsUpDownIcon,
LogOutIcon,
} from "lucide-react";
import Link from "next/link";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Expand Down Expand Up @@ -30,7 +35,14 @@ function initialsFor(account: CurrentAccount | null) {
return source.slice(0, 1).toUpperCase();
}

export function NavUser({ user }: { user: CurrentAccount | null }) {
export function NavUser({
user,
statusPageUrl = null,
}: {
user: CurrentAccount | null;
/** Public status page, when the deployment has one. */
statusPageUrl?: string | null;
}) {
const { isMobile } = useSidebar();

return (
Expand Down Expand Up @@ -83,6 +95,14 @@ export function NavUser({ user }: { user: CurrentAccount | null }) {
Account
</Link>
</DropdownMenuItem>
{statusPageUrl && (
<DropdownMenuItem asChild>
<a href={statusPageUrl} target="_blank" rel="noreferrer">
<ActivityIcon />
System status
</a>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
Expand Down
8 changes: 8 additions & 0 deletions apps/web/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export function getMaxUploadSizeBytes() {
return parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_SIZE_BYTES;
}

/** Public status page linked from the dashboard ("System status"), e.g.
* `https://status.sendlit.app`. Server-only, like the settings below; unset
* hides the link. */
export function getStatusPageUrl(): string | null {
const url = process.env.STATUS_PAGE_URL?.trim();
return url && /^https?:\/\//i.test(url) ? url : null;
}

export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";

/**
Expand Down
16 changes: 16 additions & 0 deletions apps/web/lib/status-page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { afterEach, describe, expect, it } from "bun:test";
import { getStatusPageUrl } from "./config";

describe("getStatusPageUrl", () => {
afterEach(() => {
delete process.env.STATUS_PAGE_URL;
});

it("returns an http(s) URL, and nothing otherwise", () => {
expect(getStatusPageUrl()).toBeNull();
process.env.STATUS_PAGE_URL = " https://status.sendlit.app ";
expect(getStatusPageUrl()).toBe("https://status.sendlit.app");
process.env.STATUS_PAGE_URL = "javascript:alert(1)";
expect(getStatusPageUrl()).toBeNull();
});
});
Loading