diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a8fe8e --- /dev/null +++ b/.env.example @@ -0,0 +1,58 @@ +# Copy this file to .env in the repo root, then: +# cd infra && docker compose --env-file ../.env up --build +# +# Every value marked REQUIRED_change_me must be replaced before deploying. +# Generate strong random secrets with: openssl rand -hex 32 + +# ─── Database ────────────────────────────────────────────────────────────── +POSTGRES_USER=liveboard +POSTGRES_PASSWORD=REQUIRED_change_me +POSTGRES_DB=liveboard + +# ─── Redis ───────────────────────────────────────────────────────────────── +REDIS_PASSWORD=REQUIRED_change_me + +# ─── Backend (apps/api) ──────────────────────────────────────────────────── +# Ingest master key. Also doubles as the internal BFF<->API token unless +# INTERNAL_SERVICE_TOKEN is set below. Must be >=32 random chars in production +# (core/config.py rejects weak/default values outright). +API_SECRET_KEY=REQUIRED_change_me + +# Optional: a separate token for internal BFF<->API calls, instead of reusing +# API_SECRET_KEY for that purpose. Recommended in production. If unset, the +# API falls back to accepting API_SECRET_KEY for internal calls (see +# apps/api/api/deps.py::_valid_internal_token). +INTERNAL_SERVICE_TOKEN= + +# Optional: Cerebras API key for AI-written incident summaries. Leave empty +# to disable AI summaries — anomaly detection still runs, just without the +# generated write-up. +CEREBRAS_API_KEY= + +ENVIRONMENT=production + +# ─── Frontend (Next.js) ──────────────────────────────────────────────────── +# Public URL of the API, reachable from the browser. +NEXT_PUBLIC_API_URL=https://api.yourdomain.com + +# Public URL this frontend is deployed at. +NEXTAUTH_URL=https://yourdomain.com + +# Session encryption secret for NextAuth/Auth.js. +AUTH_SECRET=REQUIRED_change_me + +# Google OAuth. Required for real sign-in in production — without it, and +# with DISABLE_DEV_LOGIN=true (below), nobody can sign in at all. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Restrict who's allowed to sign up. Leave both empty to allow anyone with a +# Google account (open sign-up) — fine for a personal instance, probably not +# for a team/company one. +ALLOWED_EMAILS= +ALLOWED_EMAIL_DOMAIN= + +# Hard kill-switch for the dev-login credentials bypass (accepts any email +# with zero verification), independent of NODE_ENV. Always set this to true +# outside of local development. +DISABLE_DEV_LOGIN=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f579e85..835c7e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: cache: pip - working-directory: apps/api - run: pip install ruff && ruff check . + run: pip install ruff==0.15.17 && ruff check . sdk-js: name: SDK JS — Typecheck & Build @@ -69,7 +69,7 @@ jobs: cache: pip - working-directory: packages/sdk-python - run: pip install ruff httpx && ruff check liveboard/ + run: pip install ruff==0.15.17 httpx && ruff check liveboard/ - working-directory: packages/sdk-python run: python -c "from liveboard import SDK_VERSION, LiveBoardConfig; print('import OK', SDK_VERSION)" diff --git a/.gitignore b/.gitignore index 1dcc6eb..d8298bb 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ # Next.js /.next/ +.agents +.claude /out/ /build @@ -19,7 +21,7 @@ next-env.d.ts .env.*.local # Note: .env.example is intentionally NOT listed here -# ─── Python ────────────────────────────────────────────────────────────────── +# ─── Python ───────────────────────────────────────────────────────────────── __pycache__/ *.py[cod] *.egg-info/ @@ -55,4 +57,4 @@ Thumbs.db # ─── Confidential — never commit the build plan ────────────────────────────── *.pdf /checklist.md -.env.example +.ground.md diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index f189a50..d986d86 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -1,9 +1,12 @@ "use client"; +import Link from "next/link"; import { signIn } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { Suspense, useState } from "react"; +import { AlertCircle, Terminal } from "lucide-react"; import { LiveboardIcon } from "@/components/logo"; +import { primaryButtonClass, secondaryButtonClass } from "@/components/landing/button-styles"; const hasGoogle = Boolean(process.env.NEXT_PUBLIC_GOOGLE_ENABLED) || @@ -37,102 +40,103 @@ function SignInForm() { } return ( -
+
- {/* Logo */} -
- - - Liveboard - -
- -
-
-

Welcome back

-

Sign in to your Liveboard workspace

+ + + Liveboard + + +
+
+

Sign in to Liveboard

+

New here? Signing in creates your workspace automatically.

{error && ( -
- {ERROR_MESSAGES[error] ?? "Authentication error. Please try again."} +
+ + {ERROR_MESSAGES[error] ?? "Authentication error. Please try again."}
)} - {/* Google button — shown when GOOGLE_CLIENT_ID is configured */} - {hasGoogle && ( - - )} - - {/* Dev login form — shown in development when Google isn't set up */} - {isDev && ( - <> - {hasGoogle && ( -
-
- dev login -
-
- )} -
- {!hasGoogle && ( -
-

- Dev mode.{" "} - Google OAuth not configured — sign in with any email below. - Set GOOGLE_CLIENT_ID to enable Google. -

+
+ {hasGoogle && ( + + )} + + {isDev && ( +
+ {hasGoogle && ( +
+
+ or +
)} -
- setEmail(e.target.value)} - required - className="w-full px-3 py-2 rounded border border-[#2A2A2A] bg-[#161616] text-sm text-[#F5F5F5] placeholder-[#333] outline-none focus:border-blue/50 transition-colors" - /> - setName(e.target.value)} - className="w-full px-3 py-2 rounded border border-[#2A2A2A] bg-[#161616] text-sm text-[#F5F5F5] placeholder-[#333] outline-none focus:border-blue/50 transition-colors" - /> + +
+ + Development sign-in
- - - - )} - {!hasGoogle && !isDev && ( -

- Google OAuth is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. -

- )} +
+
+ + setEmail(e.target.value)} + required + className="w-full border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-dark focus:border-blue" + /> +
+
+ + setName(e.target.value)} + className="w-full border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-dark focus:border-blue" + /> +
+ +
+ +

+ No password needed in development. This form is hidden in production once Google OAuth is configured. +

+
+ )} + + {!hasGoogle && !isDev && ( +

+ Google OAuth is not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET. +

+ )} +
-

- By signing in you agree to our{" "} - Terms of Service - {" "}and{" "} - Privacy Policy. +

+ By signing in you agree to our Terms of Service and Privacy Policy.

- -

Open source API observability

); @@ -140,7 +144,13 @@ function SignInForm() { export default function SignInPage() { return ( - }> + +
+
+ } + >
); diff --git a/app/page.tsx b/app/page.tsx index 2d71cde..5f0f532 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,89 +1,32 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; -import { LandingNavbar } from "@/components/landing/navbar"; -import { ScrollProgress } from "@/components/landing/scroll-progress"; +import { ScrollIndicator } from "@/components/landing/scroll-indicator"; +import { Navbar } from "@/components/landing/navbar"; import { Hero } from "@/components/landing/hero"; -import { IntegrationsStrip } from "@/components/landing/integrations-strip"; -import { Showcase } from "@/components/landing/showcase"; +import { MarqueeStrip } from "@/components/landing/marquee-strip"; import { FeatureGrid } from "@/components/landing/feature-grid"; -import { HowItWorks } from "@/components/landing/how-it-works"; +import { IncidentSection } from "@/components/landing/incident-section"; +import { StatusSection } from "@/components/landing/status-section"; +import { StatsSection } from "@/components/landing/stats-section"; import { CtaSection } from "@/components/landing/cta-section"; -import { LandingFooter } from "@/components/landing/footer"; +import { Footer } from "@/components/landing/footer"; export default async function Home() { const session = await auth(); if (session) redirect("/overview"); return ( -
- - +
+ + - - - - - - - - + - - - - + + + - +
); } diff --git a/auth.ts b/auth.ts index 2f30061..05e00d0 100644 --- a/auth.ts +++ b/auth.ts @@ -52,7 +52,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ ] : []), - ...(process.env.NODE_ENV !== "production" + // Gated on two independent conditions, not just NODE_ENV: this provider + // authenticates as whatever email is typed in, with zero proof of + // ownership. NODE_ENV=production already excludes it from the real + // Docker image (see Dockerfile.frontend), but that's one setting one + // deploy path away from being wrong. DISABLE_DEV_LOGIN is a second, + // explicit switch for any non-production environment (staging, a demo + // box) that still holds real user data and is reachable over a network. + ...(process.env.NODE_ENV !== "production" && process.env.DISABLE_DEV_LOGIN !== "true" ? [ Credentials({ id: "dev-login", @@ -64,10 +71,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ authorize(credentials) { if (!credentials?.email) return null; const email = credentials.email as string; + // Pass an empty field through as null rather than defaulting it + // to the email prefix here — the backend already falls back to + // the email prefix for brand-new users, and preserves the + // existing name (COALESCE) for returning ones. Resolving a + // default on the client would defeat that preservation on + // every subsequent sign-in. + const typedName = (credentials.name as string)?.trim(); return { id: email, email, - name: (credentials.name as string) || email.split("@")[0], + name: typedName || null, image: null, }; }, diff --git a/components/landing/animated-counter.tsx b/components/landing/animated-counter.tsx new file mode 100644 index 0000000..877f06d --- /dev/null +++ b/components/landing/animated-counter.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { animate, useInView, useReducedMotion } from "framer-motion"; + +interface AnimatedCounterProps { + target: number; + decimals?: number; + prefix?: string; + suffix?: string; +} + +export function AnimatedCounter({ target, decimals = 0, prefix = "", suffix = "" }: AnimatedCounterProps) { + const ref = useRef(null); + const inView = useInView(ref, { once: true, margin: "-10% 0px -10% 0px" }); + const reduceMotion = useReducedMotion(); + const [display, setDisplay] = useState(reduceMotion ? target : 0); + + useEffect(() => { + if (!inView || reduceMotion) return; + const controls = animate(0, target, { + duration: 1.2, + ease: [0.19, 1, 0.22, 1], + onUpdate: (v) => setDisplay(v), + }); + return () => controls.stop(); + }, [inView, target, reduceMotion]); + + return ( + + {prefix} + {display.toFixed(decimals)} + {suffix} + + ); +} diff --git a/components/landing/browser-frame.tsx b/components/landing/browser-frame.tsx deleted file mode 100644 index 5bb0be6..0000000 --- a/components/landing/browser-frame.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import Image from "next/image"; - -interface BrowserFrameProps { - src: string; - alt: string; - url?: string; - width: number; - height: number; - priority?: boolean; - live?: boolean; -} - -export function BrowserFrame({ src, alt, url = "app.liveboard.dev", width, height, priority, live }: BrowserFrameProps) { - return ( -
-
-
- - - -
-
- - - - - {url} -
- {live && ( -
- - Live -
- )} -
- {alt} -
- ); -} diff --git a/components/landing/button-styles.ts b/components/landing/button-styles.ts new file mode 100644 index 0000000..e5f6a37 --- /dev/null +++ b/components/landing/button-styles.ts @@ -0,0 +1,21 @@ +// Shared hover treatment for every button-shaped CTA on the landing page: +// a flat blue fill sweeps in from the left on hover (the same blue used in +// the Liveboard mark), text flips to white for contrast. Kept out of plain +// text links (nav items, footer links) — this is for buttons only. +const BASE = + "group relative isolate inline-flex items-center justify-center gap-1.5 overflow-hidden border font-medium transition-colors duration-300 before:absolute before:inset-0 before:-z-10 before:origin-left before:scale-x-0 before:bg-blue before:transition-transform before:duration-300 before:content-['']"; + +const SIZES = { + sm: "px-4 py-2 text-[13px]", + md: "px-5 py-2.5 text-sm", +} as const; + +type Size = keyof typeof SIZES; + +export function primaryButtonClass(size: Size = "md", extra = "") { + return `${BASE} ${SIZES[size]} border-foreground bg-foreground text-background hover:text-white hover:before:scale-x-100 ${extra}`; +} + +export function secondaryButtonClass(size: Size = "md", extra = "") { + return `${BASE} ${SIZES[size]} border-border text-foreground hover:border-blue hover:text-white hover:before:scale-x-100 ${extra}`; +} diff --git a/components/landing/channel-marks.tsx b/components/landing/channel-marks.tsx new file mode 100644 index 0000000..d39004d --- /dev/null +++ b/components/landing/channel-marks.tsx @@ -0,0 +1,35 @@ +import { Hash, Webhook } from "lucide-react"; + +interface ChannelMark { + key: string; + label: string; + slug?: string; +} + +// Discord and PagerDuty are real brand marks (Simple Icons CDN). Slack's mark +// was removed from Simple Icons, so it falls back to a generic glyph like +// "Webhook" — a delivery method, not a brand. +export const ALERT_CHANNELS: ChannelMark[] = [ + { key: "slack", label: "Slack" }, + { key: "discord", label: "Discord", slug: "discord" }, + { key: "pagerduty", label: "PagerDuty", slug: "pagerduty" }, + { key: "webhook", label: "Webhook" }, +]; + +export function ChannelMarkIcon({ channel, className }: { channel: ChannelMark; className?: string }) { + if (!channel.slug) { + const Icon = channel.key === "slack" ? Hash : Webhook; + return ; + } + return ( + // eslint-disable-next-line @next/next/no-img-element -- external brand mark, next/image needs SVG allow-listing for no benefit here + {channel.label} + ); +} diff --git a/components/landing/code-snippet.tsx b/components/landing/code-snippet.tsx deleted file mode 100644 index daa9ec0..0000000 --- a/components/landing/code-snippet.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { cn } from "@/lib/utils"; - -/* Lightweight, hand-tokenized syntax highlighting for the handful of code - snippets on the landing page. Palette tuned to the dark theme. */ - -export const tok = { - fn: "text-[#6aa9f0]", // functions / methods / commands - str: "text-[#5ac98a]", // strings, packages, urls - prop: "text-[#c795f0]", // properties / identifiers of note - txt: "text-[#d4d4dc]", // plain identifiers - punc: "text-[#5f5f68]", // punctuation / brackets - prompt: "text-[#4a4a52] select-none", // shell prompt / arrow -}; - -export function CodeSnippet({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - {children} - ); -} - -// ── Prebuilt, reused snippets ──────────────────────────────────────────────── - -export function InstallSnippet({ prompt = true }: { prompt?: boolean }) { - return ( - - {prompt && $ } - npm - install - liveboard-sdk - - ); -} - -export function MiddlewareSnippet() { - return ( - - app - .use - ( - liveboard - .middleware - ({"{ "} - apiKey - {" }"}) - ) - - ); -} - -export function DashboardUrlSnippet() { - return ( - - → - liveboard.dev/overview - - ); -} diff --git a/components/landing/count-up.tsx b/components/landing/count-up.tsx deleted file mode 100644 index 6515755..0000000 --- a/components/landing/count-up.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { animate, useInView } from "framer-motion"; - -interface CountUpProps { - to: number; - decimals?: number; - prefix?: string; - suffix?: string; - duration?: number; -} - -export function CountUp({ to, decimals = 0, prefix = "", suffix = "", duration = 1.6 }: CountUpProps) { - const ref = useRef(null); - const inView = useInView(ref, { once: true, margin: "-80px" }); - const [value, setValue] = useState(0); - - useEffect(() => { - if (!inView) return; - const controls = animate(0, to, { - duration, - ease: [0.22, 1, 0.36, 1], - onUpdate: (v) => setValue(v), - }); - return () => controls.stop(); - }, [inView, to, duration]); - - return ( - - {prefix} - {value.toFixed(decimals)} - {suffix} - - ); -} diff --git a/components/landing/cta-section.tsx b/components/landing/cta-section.tsx index b44295d..ed67848 100644 --- a/components/landing/cta-section.tsx +++ b/components/landing/cta-section.tsx @@ -1,41 +1,60 @@ import Link from "next/link"; -import { ArrowRight } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { GithubIcon } from "@/components/landing/github-icon"; -import { Reveal } from "@/components/landing/reveal"; +import { ArrowUpRight, Activity, Container } from "lucide-react"; +import { RevealOnScroll } from "@/components/landing/reveal-on-scroll"; export function CtaSection() { return ( -
- {/* subtle monochrome focus + faint radar echo */} -
-
-
-
-
- -

- Ship with confidence. +
+ +

+ One line of middleware. Zero blind spots.

-

- Open source, self-hostable, and free to start. Find out about the incident - before your customers do. +

+ Self-host the whole stack, or sign in and start right here.

-
- - + + + - + +
); } diff --git a/components/landing/feature-grid.tsx b/components/landing/feature-grid.tsx index 3d07015..65cc6aa 100644 --- a/components/landing/feature-grid.tsx +++ b/components/landing/feature-grid.tsx @@ -1,180 +1,72 @@ -import { Bell, Gauge, Sparkles, Users, Zap } from "lucide-react"; -import { Reveal } from "@/components/landing/reveal"; -import { SpotlightCard } from "@/components/landing/spotlight-card"; -import { InstallSnippet } from "@/components/landing/code-snippet"; - -const UPTIME_DAYS = Array.from({ length: 44 }, (_, i) => (i === 29 ? "yellow" : i === 30 ? "red" : "green")); - -const TOP_USERS = [ - { id: "u_2291", reqs: "12.4k", color: "#3B82F6" }, - { id: "u_0144", reqs: "9.8k", color: "#A855F7" }, - { id: "u_3387", reqs: "7.1k", color: "#06B6D4" }, -]; - -function Eyebrow({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - ); -} - -function cardShell(extra = "") { - return `h-full rounded-2xl hairline hairline-hover p-6 ${extra}`; -} +import { RevealOnScroll } from "@/components/landing/reveal-on-scroll"; +import { ScreenshotFrame } from "@/components/landing/screenshot-frame"; +import { ALERT_CHANNELS, ChannelMarkIcon } from "@/components/landing/channel-marks"; +import { InstallSnippet } from "@/components/landing/install-snippet"; export function FeatureGrid() { return ( -
- - Platform -

- Everything you need to know -
what your API is doing +
+ +

+ Every request, instrumented.

-

- Not another generic APM dashboard — built around the questions you actually ask at 2am. +

+ Distributed traces, per-endpoint health scores, and alerts routed to the tools your team already uses.

- +
-
- {/* Onboarding */} - - -
- -

60-second onboarding

-

- One line of middleware. No agents, no sidecars, no config files to maintain. -

-
- -
-
-
-
+ + + - {/* Endpoints / health */} - - -
- -

Per-endpoint health

-

- p50 / p95 / p99, error rate and a rolling health score for every route you serve. -

-
- {[ - { r: "/checkout", p: 92, c: "#22C55E" }, - { r: "/orders", p: 68, c: "#F59E0B" }, - { r: "/search", p: 34, c: "#EF4444" }, - ].map((e) => ( -
- {e.r} -
-
-
- {e.p} -
- ))} -
-
- - +
+ + +
+

Distributed tracing

+

+ Flame graphs and service maps for every request, down to the individual span. Trace IDs propagate across all five SDK adapters automatically. +

+
+
- {/* AI incident summaries — featured, tall */} - - -
-
- - - AI - -
-

AI incident summaries

-

- When error rate or p99 latency breaks 3σ from baseline, an LLM reads the surrounding - traffic and writes the postmortem before you do. +

+ + +
+

Endpoint health scores

+

+ Sort by error rate or p99 latency and catch a regression before it pages anyone.

- -
-
- - Critical - - 2m ago -
-

- p99 latency spike on /api/checkout -

-

- Latency jumped 6.4× starting 14:02 UTC, correlated with a downstream timeout from the - payments provider. 312 requests affected. Recommend a circuit breaker around the - payment client. -

-
- - Resolved in 4m -
-
- - +
- {/* User-centric */} - - -
- -

User-centric observability

-

- Every event carries a user ID. Know exactly who an incident hits, not just which route. + +

+

Alerts that reach your team

+

+ Threshold rules on error rate or latency, routed to the channel your on-call rotation actually watches.

-
- {TOP_USERS.map((u) => ( -
- - {u.id} - {u.reqs} req -
- ))} -
- - - - {/* Alerts + status page combined-ish */} - - -
- -

Alerts + status page

-

- Route anomalies to Slack, PagerDuty or a webhook — and ship a public status page for free. -

-
-
- 99.97% uptime - 44 days -
-
- {UPTIME_DAYS.map((color, i) => ( - - ))} +
+ {ALERT_CHANNELS.map((channel) => ( +
+ + {channel.label}
-
+ ))}
- - + +
); diff --git a/components/landing/footer.tsx b/components/landing/footer.tsx index 6c33896..63e1c5a 100644 --- a/components/landing/footer.tsx +++ b/components/landing/footer.tsx @@ -1,6 +1,5 @@ import Link from "next/link"; import { LiveboardIcon } from "@/components/logo"; -import { GithubIcon } from "@/components/landing/github-icon"; interface FooterLink { label: string; @@ -13,45 +12,38 @@ const COLUMNS: { title: string; links: FooterLink[] }[] = [ title: "Product", links: [ { label: "Features", href: "#features" }, - { label: "How it works", href: "#how-it-works" }, - { label: "Status page", href: "/status" }, + { label: "Anomaly detection", href: "#detection" }, + { label: "Status page", href: "#status" }, ], }, { title: "Developers", links: [ { label: "GitHub", href: "https://github.com/ryzrr/liveboard", external: true }, - { label: "JS SDK (npm)", href: "https://github.com/ryzrr/liveboard/tree/main/packages/sdk-js", external: true }, - { label: "Python SDK (PyPI)", href: "https://github.com/ryzrr/liveboard/tree/main/packages/sdk-python", external: true }, - ], - }, - { - title: "Account", - links: [ - { label: "Sign in", href: "/auth/signin" }, - { label: "Dashboard", href: "/overview" }, + { label: "JS SDK", href: "https://github.com/ryzrr/liveboard/tree/main/packages/sdk-js", external: true }, + { label: "Python SDK", href: "https://github.com/ryzrr/liveboard/tree/main/packages/sdk-python", external: true }, ], }, ]; -export function LandingFooter() { +export function Footer() { return ( -