diff --git a/alchemy.run.ts b/alchemy.run.ts index 867f88bf6..40fca03c1 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -7,6 +7,7 @@ import { createMapleApi } from "./apps/api/alchemy.run.ts" import { createChatFlueWorker } from "./apps/chat-flue/alchemy.run.ts" import { createElectricSyncWorker } from "./apps/electric-sync/alchemy.run.ts" import { createLandingWorker } from "./apps/landing/alchemy.run.ts" +import { createLocalLandingWorker } from "./apps/local-landing/alchemy.run.ts" import { createLocalUiWorker } from "./apps/local-ui/alchemy.run.ts" import { createMapleWeb } from "./apps/web/alchemy.run.ts" @@ -72,6 +73,8 @@ const landing = await createLandingWorker({ stage, domains }) const localUi = await createLocalUiWorker({ stage, domains }) +const localLanding = await createLocalLandingWorker({ stage, domains }) + const alerting = await createAlertingWorker({ stage, domains, mapleDb }) const summary = { @@ -83,6 +86,7 @@ const summary = { webUrl: domains.web ? `https://${domains.web}` : web.url, landingUrl: domains.landing ? `https://${domains.landing}` : landing.url, localUiUrl: domains.local ? `https://${domains.local}` : localUi.url, + localLandingUrl: domains.localLanding ? `https://${domains.localLanding}` : localLanding.url, alertingWorker: alerting.name, } diff --git a/apps/landing/src/components/live/LiveServiceMap.astro b/apps/landing/src/components/live/LiveServiceMap.astro index b5cb3ba5a..165691606 100644 --- a/apps/landing/src/components/live/LiveServiceMap.astro +++ b/apps/landing/src/components/live/LiveServiceMap.astro @@ -229,7 +229,7 @@ const VIEW_H = 400; const y = ROWS_PER_COL[n.col][n.row] - NODE_H / 2; const accent = serviceColorVar(n.label); const errNum = n.err ? parseFloat(n.err) : 0; - const healthClass = errNum > 1 ? "svc-map-health--warn" : errNum > 0.05 ? "svc-map-health--info" : "svc-map-health--info"; + const healthClass = errNum > 1 ? "svc-map-health--error" : errNum > 0.3 ? "svc-map-health--warn" : "svc-map-health--info"; return ( {n.hot && ( diff --git a/apps/landing/src/components/local/LocalCommandGrid.astro b/apps/landing/src/components/local/LocalCommandGrid.astro index b23e21bbb..d19675734 100644 --- a/apps/landing/src/components/local/LocalCommandGrid.astro +++ b/apps/landing/src/components/local/LocalCommandGrid.astro @@ -4,7 +4,7 @@ * Each card header links to the matching section of the CLI reference doc * (slugs must stay in sync with the H2 headings in local-mode/cli-reference.md). */ -const REFERENCE = "/docs/local-mode/cli-reference"; +const REFERENCE = "https://maplelocal.dev/docs/reference/cli"; const groups: Array<{ name: string; diff --git a/apps/landing/src/components/local/LocalProductHero.astro b/apps/landing/src/components/local/LocalProductHero.astro index debfee6ad..c5312ab89 100644 --- a/apps/landing/src/components/local/LocalProductHero.astro +++ b/apps/landing/src/components/local/LocalProductHero.astro @@ -92,7 +92,7 @@ const stats = [ View on GitHub Read the docs diff --git a/apps/landing/src/content/docs/local-mode.mdx b/apps/landing/src/content/docs/local-mode.mdx index f61b1cf05..4549c3f8b 100644 --- a/apps/landing/src/content/docs/local-mode.mdx +++ b/apps/landing/src/content/docs/local-mode.mdx @@ -3,6 +3,7 @@ title: "Maple Local" description: "Run Maple as a single binary on your machine — OTLP ingest, an embedded ClickHouse, a query API, and the dashboard, with no cloud and no auth." group: "Local Mode" order: 1 +draft: true --- import LocalHero from "../../components/local/LocalHero.astro" diff --git a/apps/landing/src/content/docs/local-mode/cli-reference.md b/apps/landing/src/content/docs/local-mode/cli-reference.md index fc3655e37..4a6cbbb74 100644 --- a/apps/landing/src/content/docs/local-mode/cli-reference.md +++ b/apps/landing/src/content/docs/local-mode/cli-reference.md @@ -3,6 +3,7 @@ title: "CLI Reference" description: "Every maple command, argument, and flag — plus the server endpoints, environment variables, and troubleshooting for local mode." group: "Local Mode" order: 2 +draft: true --- The `maple` binary is one CLI with two backends: a local server (`maple start`) and a remote workspace (`maple login`). Every query command runs against whichever is [resolved](#auth-and-configuration) for that invocation. Output is JSON by default — clean enough to pipe into `jq` or an agent. diff --git a/apps/landing/src/lib/docs-nav.ts b/apps/landing/src/lib/docs-nav.ts index e01ed8989..441955f26 100644 --- a/apps/landing/src/lib/docs-nav.ts +++ b/apps/landing/src/lib/docs-nav.ts @@ -13,7 +13,6 @@ export const UNIVERSAL_GROUP_ORDER = [ "Infrastructure", "Integrations", "Alerting", - "Local Mode", ] as const /** Order of SDK-scoped groups (Effect SDK pages). */ @@ -46,7 +45,6 @@ export const HEADER_NAV: HeaderNavItem[] = [ { key: "Infrastructure", icon: "Infrastructure", kind: "group" }, { key: "Integrations", icon: "Integrations", kind: "group" }, { key: "Alerting", icon: "Alerting", kind: "group" }, - { key: "Local Mode", icon: "Local Mode", kind: "group" }, ] /** Group names (and the synthetic `sdks` key) that have a DocsCategoryIcon glyph. */ @@ -57,7 +55,6 @@ const CATEGORY_ICON_KEYS = new Set([ "Infrastructure", "Integrations", "Alerting", - "Local Mode", "sdks", ]) diff --git a/apps/landing/src/pages/local.astro b/apps/landing/src/pages/local.astro index b39439adb..95b633bfb 100644 --- a/apps/landing/src/pages/local.astro +++ b/apps/landing/src/pages/local.astro @@ -206,11 +206,17 @@ const valueProps = [
Read the docs + + Visit maplelocal.dev + Promise } } +// The Local Mode docs moved to the standalone Maple Local site +// (apps/local-landing). These 301s must ship together with (or after) the +// maplelocal.dev zone going live — see apps/local-landing/README.md. +const REDIRECTS: Record = { + "/docs/local-mode": "https://maplelocal.dev/docs/getting-started/quickstart", + "/docs/local-mode/cli-reference": "https://maplelocal.dev/docs/reference/cli", +} + export default { async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url) + + const redirect = REDIRECTS[url.pathname.replace(/\/$/, "")] + if (redirect) return Response.redirect(redirect, 301) + const assetResponse = await env.ASSETS.fetch(request) if (assetResponse.status !== 404) return assetResponse - const url = new URL(request.url) const notFound = await env.ASSETS.fetch(new Request(new URL("/404.html", url), request)) return new Response(notFound.body, { status: 404, diff --git a/apps/local-landing/.gitignore b/apps/local-landing/.gitignore new file mode 100644 index 000000000..ba33de91a --- /dev/null +++ b/apps/local-landing/.gitignore @@ -0,0 +1,26 @@ +# build output +dist/ +# generated from scripts/install.sh by the `sync:cli` script (served at /cli/install) +public/cli/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/apps/local-landing/README.md b/apps/local-landing/README.md new file mode 100644 index 000000000..d0271e087 --- /dev/null +++ b/apps/local-landing/README.md @@ -0,0 +1,32 @@ +# @maple/local-landing + +Standalone marketing + docs site for **Maple Local** — the single-binary local +OpenTelemetry tool built from `apps/cli`. Astro 5, same amber/Geist branding as +`apps/landing`, deployed as a Cloudflare Worker (static assets + tiny +`src/worker.ts` passthrough) via Alchemy. + +```bash +bun run --cwd apps/local-landing dev:app # dev server on :3392 (or `bun dev` via portless) +bun run --cwd apps/local-landing build # static build into dist/ +``` + +Docs live in `src/content/docs/` (groups: Getting Started, Sending Data, +Reference, Operations — ordering in `src/lib/docs-nav.ts`). The `build`/`dev` +scripts copy `scripts/install.sh` / `uninstall.sh` into `public/cli/` so the +site serves `/cli/install` like maple.dev does. + +## Domain setup (manual, one-time) + +The site targets the apex domain `maplelocal.dev`, which is **not** created by +the deploy — Alchemy can only attach a worker to a zone that already exists: + +1. Buy `maplelocal.dev` and add it as a new zone in the same Cloudflare account. +2. Point the registrar's nameservers at Cloudflare and wait for the zone to go + active. +3. Uncomment the `localLanding` entries in + `packages/infra/src/cloudflare/stage.ts` (`PRD_DOMAINS` / `STG_DOMAINS`). +4. Recommended: add a `www` → apex redirect rule in the zone. + +Until then the worker deploys on its workers.dev URL (same fallback as PR +previews). If a different apex is bought instead, update the two entries in +`stage.ts` and `site` in `astro.config.mjs`. diff --git a/apps/local-landing/alchemy.run.ts b/apps/local-landing/alchemy.run.ts new file mode 100644 index 000000000..984cbc4d2 --- /dev/null +++ b/apps/local-landing/alchemy.run.ts @@ -0,0 +1,44 @@ +import { spawnSync } from "node:child_process" +import path from "node:path" +import { Assets, Worker } from "alchemy/cloudflare" +import { + CLOUDFLARE_WORKER_PLACEMENT, + resolveWorkerName, + type MapleDomains, + type MapleStage, +} from "@maple/infra/cloudflare" + +export interface CreateLocalLandingWorkerOptions { + stage: MapleStage + domains: MapleDomains +} + +export const createLocalLandingWorker = async ({ stage, domains }: CreateLocalLandingWorkerOptions) => { + const isDestroy = process.argv.some((arg) => arg === "destroy") + if (!isDestroy) { + const build = spawnSync("bun", ["run", "build"], { + stdio: "inherit", + cwd: import.meta.dirname, + env: process.env, + }) + if (build.status !== 0) { + throw new Error(`local-landing build failed with exit code ${build.status ?? "unknown"}`) + } + } + + const worker = await Worker("local-landing", { + name: resolveWorkerName("local-landing", stage), + cwd: import.meta.dirname, + entrypoint: path.join(import.meta.dirname, "src", "worker.ts"), + compatibility: "node", + placement: CLOUDFLARE_WORKER_PLACEMENT, + url: true, + adopt: true, + domains: domains.localLanding ? [{ domainName: domains.localLanding, adopt: true }] : undefined, + bindings: { + ASSETS: await Assets({ path: path.join(import.meta.dirname, "dist") }), + }, + }) + + return worker +} diff --git a/apps/local-landing/astro.config.mjs b/apps/local-landing/astro.config.mjs new file mode 100644 index 000000000..964f12738 --- /dev/null +++ b/apps/local-landing/astro.config.mjs @@ -0,0 +1,33 @@ +// @ts-check +import { defineConfig } from "astro/config" +import mdx from "@astrojs/mdx" +import react from "@astrojs/react" +import sitemap from "@astrojs/sitemap" +import tailwindcss from "@tailwindcss/vite" + +// https://astro.build/config +export default defineConfig({ + site: "https://maplelocal.dev", + trailingSlash: "ignore", + markdown: { + shikiConfig: { + theme: "vitesse-dark", + wrap: true, + }, + }, + integrations: [ + mdx(), + react(), + sitemap({ + // Stamp a build-time lastmod so the sitemap exposes a freshness signal. + serialize(item) { + item.lastmod = new Date().toISOString() + return item + }, + }), + ], + vite: { + plugins: [tailwindcss()], + envDir: "../../", + }, +}) diff --git a/apps/local-landing/package.json b/apps/local-landing/package.json new file mode 100644 index 000000000..33f2f6346 --- /dev/null +++ b/apps/local-landing/package.json @@ -0,0 +1,42 @@ +{ + "name": "@maple/local-landing", + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "portless", + "sync:cli": "mkdir -p public/cli && cp ../../scripts/install.sh public/cli/install && cp ../../scripts/uninstall.sh public/cli/uninstall", + "dev:app": "bun run sync:cli && astro dev --port ${PORT:-3392} --host ${HOST:-127.0.0.1}", + "build": "bun run sync:cli && astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/mdx": "^5.0.3", + "@astrojs/react": "^4.4.2", + "@astrojs/sitemap": "^3.7.0", + "@fontsource-variable/geist": "^5.2.9", + "@fontsource-variable/geist-mono": "^5.2.8", + "@maple/infra": "workspace:*", + "@maple/ui": "workspace:*", + "@tailwindcss/vite": "catalog:tailwind", + "@types/react": "catalog:react", + "@types/react-dom": "catalog:react", + "alchemy": "^0.93.12", + "astro": "^5.17.1", + "fuse.js": "^7.4.2", + "motion": "^12.40.0", + "react": "catalog:react", + "react-dom": "catalog:react", + "recharts": "catalog:ui", + "tailwindcss": "catalog:tailwind", + "tw-animate-css": "catalog:tailwind" + }, + "devDependencies": { + "@astrojs/check": "^0.9.9", + "typescript": "^6.0.3" + }, + "portless": { + "name": "local-landing", + "script": "dev:app" + } +} diff --git a/apps/local-landing/public/favicon.ico b/apps/local-landing/public/favicon.ico new file mode 100644 index 000000000..b5fdeef65 Binary files /dev/null and b/apps/local-landing/public/favicon.ico differ diff --git a/apps/local-landing/public/favicon.svg b/apps/local-landing/public/favicon.svg new file mode 100644 index 000000000..7ca3971b5 --- /dev/null +++ b/apps/local-landing/public/favicon.svg @@ -0,0 +1,4 @@ + + + M + diff --git a/apps/local-landing/public/logo192.png b/apps/local-landing/public/logo192.png new file mode 100644 index 000000000..e1317e2c7 Binary files /dev/null and b/apps/local-landing/public/logo192.png differ diff --git a/apps/local-landing/public/logo512.png b/apps/local-landing/public/logo512.png new file mode 100644 index 000000000..31fbf8c25 Binary files /dev/null and b/apps/local-landing/public/logo512.png differ diff --git a/apps/local-landing/public/og-image.png b/apps/local-landing/public/og-image.png new file mode 100644 index 000000000..fbc79bc50 Binary files /dev/null and b/apps/local-landing/public/og-image.png differ diff --git a/apps/local-landing/public/robots.txt b/apps/local-landing/public/robots.txt new file mode 100644 index 000000000..99570f578 --- /dev/null +++ b/apps/local-landing/public/robots.txt @@ -0,0 +1,45 @@ +# https://www.robotstxt.org/robotstxt.html + +# Default: allow all crawlers full access. +User-agent: * +Disallow: + +# Explicitly welcome AI search & answer engines. +# Citation by AI chatbots is impossible if these are blocked, so we opt in by name. +User-agent: GPTBot +Disallow: + +User-agent: OAI-SearchBot +Disallow: + +User-agent: ChatGPT-User +Disallow: + +User-agent: ClaudeBot +Disallow: + +User-agent: Claude-SearchBot +Disallow: + +User-agent: Claude-User +Disallow: + +User-agent: anthropic-ai +Disallow: + +User-agent: PerplexityBot +Disallow: + +User-agent: Perplexity-User +Disallow: + +User-agent: Google-Extended +Disallow: + +User-agent: Applebot-Extended +Disallow: + +User-agent: CCBot +Disallow: + +Sitemap: https://maplelocal.dev/sitemap-index.xml diff --git a/apps/local-landing/src/components/BottomCTA.astro b/apps/local-landing/src/components/BottomCTA.astro new file mode 100644 index 000000000..b0d8708a1 --- /dev/null +++ b/apps/local-landing/src/components/BottomCTA.astro @@ -0,0 +1,58 @@ +
+ +
+ +
+
+ + diff --git a/apps/local-landing/src/components/Comparison.astro b/apps/local-landing/src/components/Comparison.astro new file mode 100644 index 000000000..1baba002c --- /dev/null +++ b/apps/local-landing/src/components/Comparison.astro @@ -0,0 +1,93 @@ +--- +// Honest comparison against the usual ways of looking at OTel data locally. +// Keep factual and terse — each row links to the project it describes. +const rows = [ + { + name: "Maple Local", + href: "/", + install: "One binary (brew or curl)", + signals: "Traces, logs, metrics", + storage: "Embedded ClickHouse (chDB)", + auth: "None — loopback only", + cli: "Full query CLI", + highlight: true, + }, + { + name: "Jaeger all-in-one", + href: "https://www.jaegertracing.io/", + install: "One binary or docker", + signals: "Traces only", + storage: "In-memory / Badger", + auth: "None", + cli: "—", + }, + { + name: "SigNoz", + href: "https://signoz.io/", + install: "docker-compose (multi-container)", + signals: "Traces, logs, metrics", + storage: "ClickHouse (separate container)", + auth: "Account + login", + cli: "—", + }, + { + name: "otel-tui", + href: "https://github.com/ymtdzzz/otel-tui", + install: "One binary", + signals: "Traces, logs, metrics", + storage: "In-memory (lost on exit)", + auth: "None", + cli: "TUI only", + }, + { + name: "Grafana LGTM stack", + href: "https://github.com/grafana/docker-otel-lgtm", + install: "docker (multi-service image)", + signals: "Traces, logs, metrics", + storage: "Tempo + Loki + Prometheus", + auth: "Login (default creds)", + cli: "—", + }, +]; +--- + +
+ + + + + + + + + + + + + { + rows.map((r) => ( + + + + + + + + + )) + } + +
ToolInstall footprintSignalsStorageAuthQuery CLI
+ {r.href.startsWith("http") ? ( + + {r.name} + + ) : ( + {r.name} + )} + {r.install}{r.signals}{r.storage}{r.auth}{r.cli}
+
+

+ All of these are good tools — the comparison is about local, single-developer workflows, not production + deployments. Details reflect each project's default local/dev setup as of mid-2026. +

diff --git a/apps/local-landing/src/components/Faq.astro b/apps/local-landing/src/components/Faq.astro new file mode 100644 index 000000000..6e8960d51 --- /dev/null +++ b/apps/local-landing/src/components/Faq.astro @@ -0,0 +1,52 @@ +--- +const faqs = [ + { + q: "Is my telemetry sent anywhere?", + a: `No. Ingest, storage, and queries all happen in one process bound to 127.0.0.1, and data persists in + ~/.maple/data. By default the dashboard itself is loaded from local.maple.dev + (a static page that talks back to your local server), but your telemetry never leaves the machine. + Run maple start --offline to serve the UI from the binary too — then nothing touches the + network at all.`, + }, + { + q: "What does offline mode do?", + a: `maple start --offline serves the dashboard bundled inside the binary from + 127.0.0.1 instead of loading the auto-updating hosted copy. Same-origin, no browser + local-network prompt, and it works with no internet connection.`, + }, + { + q: "Where does my data live, and how do I wipe it?", + a: `Everything is stored in ~/.maple/data (override with --data-dir) and survives + restarts. maple reset wipes the store; maple start --reset wipes and restarts + in one step.`, + }, + { + q: "How does this relate to Maple Cloud?", + a: `Same query engine, same dashboard — Maple Local runs them on your machine against an embedded + ClickHouse, while Maple Cloud is the hosted, team-wide version. Your + instrumentation is identical OTLP either way, so pointing the exporter at a cloud ingest endpoint is + the only change if you later want shared dashboards, alerting, and retention.`, + }, + { + q: "Does it run on Windows?", + a: `Not natively yet — builds ship for macOS (Apple Silicon) and Linux (x86_64 and arm64). On Windows, + use WSL2 and follow the Linux install.`, + }, +]; +--- + +
+ { + faqs.map((f) => ( +
+ + {f.q} + + +

+

+ )) + } +
diff --git a/apps/local-landing/src/components/Footer.astro b/apps/local-landing/src/components/Footer.astro new file mode 100644 index 000000000..2d61c0ed5 --- /dev/null +++ b/apps/local-landing/src/components/Footer.astro @@ -0,0 +1,64 @@ +--- +import { getGitHubStars, formatStars } from "../lib/github-stars"; + +const stars = await getGitHubStars(); +--- + + diff --git a/apps/local-landing/src/components/Nav.astro b/apps/local-landing/src/components/Nav.astro new file mode 100644 index 000000000..12fe57616 --- /dev/null +++ b/apps/local-landing/src/components/Nav.astro @@ -0,0 +1,68 @@ +--- +import { getGitHubStars, formatStars } from "../lib/github-stars"; + +const stars = await getGitHubStars(); +--- + + + + + + + diff --git a/apps/local-landing/src/components/SeoHead.astro b/apps/local-landing/src/components/SeoHead.astro new file mode 100644 index 000000000..377c7eb79 --- /dev/null +++ b/apps/local-landing/src/components/SeoHead.astro @@ -0,0 +1,74 @@ +--- +interface Props { + title: string; + description: string; + type?: "SoftwareApplication" | "WebPage"; + url?: string; + includeOrganization?: boolean; + competitorName?: string; +} + +const { title, description, type = "WebPage", url, includeOrganization = false, competitorName } = Astro.props; + +const siteUrl = Astro.site?.toString().replace(/\/$/, '') ?? 'https://maplelocal.dev'; +const resolvedUrl = + url ?? (Astro.site ? new URL(Astro.url.pathname, Astro.site).toString() : undefined); + +const jsonLd = { + "@context": "https://schema.org", + "@type": type, + name: title, + description, + ...(resolvedUrl ? { url: resolvedUrl } : {}), + ...(type === "SoftwareApplication" + ? { + applicationCategory: "DeveloperApplication", + operatingSystem: "Web", + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + }, + } + : {}), + ...(competitorName + ? { + about: [ + { + "@type": "SoftwareApplication", + name: "Maple", + applicationCategory: "DeveloperApplication", + }, + { + "@type": "SoftwareApplication", + name: competitorName, + applicationCategory: "DeveloperApplication", + }, + ], + } + : {}), +}; + +const organizationJsonLd = includeOrganization ? { + "@context": "https://schema.org", + "@type": "Organization", + "name": "Maple Local", + "url": siteUrl, + "logo": `${siteUrl}/logo192.png`, + "sameAs": [ + "https://github.com/Makisuo/maple", + "https://x.com/maple_dev", + ], +} : null; + +const webSiteJsonLd = includeOrganization ? { + "@context": "https://schema.org", + "@type": "WebSite", + "name": "Maple Local", + "url": siteUrl, +} : null; +--- + + diff --git a/apps/local-landing/src/components/live/live-logs-volume-data.ts b/apps/local-landing/src/components/live/live-logs-volume-data.ts new file mode 100644 index 000000000..01588f080 --- /dev/null +++ b/apps/local-landing/src/components/live/live-logs-volume-data.ts @@ -0,0 +1,66 @@ +/** + * Stub buckets for LiveLogsVolumeChart. 60 five-minute buckets ending now, + * with severity-stacked counts. The shape tells a story: quiet baseline → + * warn cluster around bucket 40 → small error blip at 50 → recover. + */ + +export type LogBucket = { + bucket: string + INFO: number + DEBUG: number + WARN: number + ERROR: number +} + +const NOW = Date.UTC(2026, 4, 3, 12, 0, 0) // 2026-05-03T12:00 UTC, deterministic +const BUCKET_MS = 5 * 60 * 1000 +const COUNT = 60 + +function pseudoRandom(seed: number): number { + const x = Math.sin(seed * 9301 + 49297) * 233280 + return x - Math.floor(x) +} + +export function generateBuckets(): LogBucket[] { + const out: LogBucket[] = [] + for (let i = 0; i < COUNT; i++) { + const t = new Date(NOW - (COUNT - 1 - i) * BUCKET_MS) + const noise = pseudoRandom(i + 1) + + const info = Math.round(140 + noise * 50 + (i > 38 && i < 46 ? 30 : 0)) + const debug = Math.round(45 + noise * 18) + const warn = Math.round( + 6 + noise * 4 + (i >= 38 && i <= 46 ? Math.max(0, 70 - Math.abs(42 - i) * 14) : 0), + ) + const error = Math.round( + i >= 48 && i <= 52 ? Math.max(0, 22 - Math.abs(50 - i) * 6) + noise * 3 : noise * 2, + ) + + out.push({ + bucket: t.toISOString(), + INFO: info, + DEBUG: debug, + WARN: warn, + ERROR: error, + }) + } + return out +} + +export const stubBuckets = generateBuckets() + +export function totalCount(buckets: LogBucket[]): number { + return buckets.reduce((sum, b) => sum + b.INFO + b.DEBUG + b.WARN + b.ERROR, 0) +} + +export function formatNumber(num: number): string { + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M` + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K` + return num.toLocaleString() +} + +export function formatBucketTick(value: string): string { + const d = new Date(value) + if (Number.isNaN(d.getTime())) return value + return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) +} diff --git a/apps/local-landing/src/components/live/live-trace-inspector.client.ts b/apps/local-landing/src/components/live/live-trace-inspector.client.ts new file mode 100644 index 000000000..0327d730e --- /dev/null +++ b/apps/local-landing/src/components/live/live-trace-inspector.client.ts @@ -0,0 +1,49 @@ +/** + * Wires waterfall row hover to the side-panel attribute inspector. + * Touch users get the same behaviour via `pointerenter` (rAF debounced). + */ + +export function wireInspector(traceId: string) { + const inspector = document.querySelector(`[data-trace-inspector="${traceId}"]`) + const trace = document.querySelector(`[data-trace-id="${traceId}"]`) + if (!inspector || !trace) return + + const titleEl = inspector.querySelector("[data-inspector-title]")! + const durEl = inspector.querySelector("[data-inspector-duration]")! + const attrsEl = inspector.querySelector("[data-inspector-attrs]")! + + const renderRow = (row: HTMLElement) => { + const service = row.dataset.service ?? "" + const op = row.dataset.op ?? "" + const dur = row.dataset.duration ?? "" + let attrs: Record = {} + try { + attrs = JSON.parse(row.dataset.attrs ?? "{}") + } catch { + attrs = {} + } + titleEl.textContent = `${service} · ${op}` + durEl.textContent = `${dur}ms` + attrsEl.innerHTML = renderAttrs(attrs) + } + + trace.addEventListener("pointerover", (e) => { + const row = (e.target as HTMLElement | null)?.closest(".waterfall-row") + if (row) renderRow(row) + }) +} + +function renderAttrs(attrs: Record): string { + const escape = (s: string) => + s.replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string, + ) + const rows = Object.entries(attrs) + .map( + ([k, v]) => + `
${escape(k)}
${escape(v)}
`, + ) + .join("") + return `
${rows}
` +} diff --git a/apps/local-landing/src/components/live/live-waterfall.client.ts b/apps/local-landing/src/components/live/live-waterfall.client.ts new file mode 100644 index 000000000..0fda5282a --- /dev/null +++ b/apps/local-landing/src/components/live/live-waterfall.client.ts @@ -0,0 +1,189 @@ +/** + * Drives the LiveWaterfall animation with Motion v12. Cycle structure: + * + * PLAY (totalMs * speed) — bars grow in sequence at 1/speed wall-speed. + * PAUSE (1400ms) — all bars full; slowest-span annotation reveals. + * RESET (320ms) — frame opacity fades to 0.35 (CSS transition); + * bars snap back to scaleX(0); opacity returns to 1. + * + * Each bar is its own Motion `animate()` call with explicit delay/duration + * matching its real wall-time window. `.is-active` is bound to each bar's + * lifecycle (timer at start, .finished at end) so multi-active is automatic + * and there's no per-frame index race. + * + * The cursor is one Motion `animate()` driving the `x` transform component, so + * it rides the compositor — no layout work per frame. + * + * Uses Motion's individual transform properties (scaleX, x), not the `transform` + * shorthand string keyframes — Motion can't interpolate opaque transform strings. + */ +import { animate, type AnimationPlaybackControls } from "motion" + +const PAUSE_MS = 1400 +const RESET_MS = 320 +const DEFAULT_SPEED_DIVISOR = 10 + +type Row = { + row: HTMLElement + bar: HTMLElement + startMs: number + durationMs: number +} + +export function startWaterfall(root: HTMLElement) { + if (root.dataset.waterfallAttached === "1") return + root.dataset.waterfallAttached = "1" + + const totalMs = Number(root.dataset.total ?? 200) + const speedDivisor = Number(root.dataset.speed ?? DEFAULT_SPEED_DIVISOR) + const stage = root.querySelector("[data-waterfall-stage]") ?? root + const cursor = root.querySelector("[data-trace-cursor]") + const firstTrack = root.querySelector(".span-bar-track") + + const rows: Row[] = Array.from(root.querySelectorAll(".waterfall-row")).map((row) => { + const bar = row.querySelector(".span-bar")! + const leftPct = parsePct(bar.style.left) + const widthPct = parsePct(bar.style.width) + return { + row, + bar, + startMs: (leftPct / 100) * totalMs, + durationMs: (widthPct / 100) * totalMs, + } + }) + if (rows.length === 0) return + + const slowestIdx = rows.reduce((best, r, i, all) => (r.durationMs > all[best].durationMs ? i : best), 0) + + let trackOffsetX = 0 + let trackWidth = 0 + const measureTrack = () => { + if (!firstTrack) return + const sR = stage.getBoundingClientRect() + const tR = firstTrack.getBoundingClientRect() + trackOffsetX = tR.left - sR.left + trackWidth = tR.width + } + measureTrack() + const ro = new ResizeObserver(measureTrack) + ro.observe(stage) + + // Generation token: bumping it cancels any in-flight cycle. Stale callbacks + // (timers, .finished handlers) gate on `gen === myGen` and bail otherwise. + let gen = 0 + let inflight: AnimationPlaybackControls[] = [] + let phaseTimers: number[] = [] + + const stopInflight = () => { + inflight.forEach((a) => { + try { + a.stop() + } catch {} + }) + inflight = [] + } + + const clearPhaseTimers = () => { + phaseTimers.forEach((t) => window.clearTimeout(t)) + phaseTimers = [] + } + + const armRows = () => { + rows.forEach((r) => { + r.bar.style.transform = "scaleX(0)" + r.row.classList.remove("is-active", "is-slowest") + }) + if (cursor) { + cursor.style.transform = `translate3d(${trackOffsetX}px, 0, 0)` + cursor.style.setProperty("--cursor-opacity", "0") + } + stage.style.setProperty("--frame-opacity", "1") + } + + const playOnce = (myGen: number) => { + const playSec = (totalMs * speedDivisor) / 1000 + + const barAnims: AnimationPlaybackControls[] = [] + rows.forEach((r) => { + const startSec = (r.startMs * speedDivisor) / 1000 + const durSec = Math.max(0.01, (r.durationMs * speedDivisor) / 1000) + + window.setTimeout(() => { + if (gen === myGen) r.row.classList.add("is-active") + }, startSec * 1000) + + const a = animate( + r.bar, + { scaleX: [0, 1] }, + { duration: durSec, delay: startSec, ease: "linear" }, + ) + a.finished + .then(() => { + if (gen === myGen) r.row.classList.remove("is-active") + }) + .catch(() => {}) + barAnims.push(a) + }) + + let cursorAnim: AnimationPlaybackControls | null = null + if (cursor) { + cursor.style.setProperty("--cursor-opacity", "1") + cursorAnim = animate( + cursor, + { x: [trackOffsetX, trackOffsetX + trackWidth] }, + { duration: playSec, ease: "linear" }, + ) + } + + inflight = cursorAnim ? [...barAnims, cursorAnim] : barAnims + return Promise.all(inflight.map((a) => a.finished)).catch(() => {}) + } + + const phaseDelay = (ms: number) => + new Promise((resolve) => { + const t = window.setTimeout(() => resolve(), ms) + phaseTimers.push(t) + }) + + const cycle = async () => { + const myGen = ++gen + while (gen === myGen) { + armRows() + await playOnce(myGen) + if (gen !== myGen) return + + rows[slowestIdx].row.classList.add("is-slowest") + await phaseDelay(PAUSE_MS) + if (gen !== myGen) return + rows[slowestIdx].row.classList.remove("is-slowest") + + stage.style.setProperty("--frame-opacity", "0.35") + if (cursor) cursor.style.setProperty("--cursor-opacity", "0") + await phaseDelay(RESET_MS) + } + } + + let isVisible = false + const io = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + if (!isVisible) { + isVisible = true + cycle() + } + } else if (isVisible) { + isVisible = false + gen++ + stopInflight() + clearPhaseTimers() + } + }, + { rootMargin: "40px" }, + ) + io.observe(root) +} + +function parsePct(value: string): number { + const n = parseFloat(value) + return Number.isFinite(n) ? n : 0 +} diff --git a/apps/local-landing/src/components/local/InstallTabs.astro b/apps/local-landing/src/components/local/InstallTabs.astro new file mode 100644 index 000000000..7cce8443c --- /dev/null +++ b/apps/local-landing/src/components/local/InstallTabs.astro @@ -0,0 +1,463 @@ +--- +/** + * InstallTabs — install-method switcher for the Maple Local docs page. + * + * Renders inside the docs `.docs-content` prose column, so every element carries + * an explicit scoped class (the docs "prose" is hand-rolled `.docs-content X` + * descendant rules in global.css — Tailwind utilities and `not-prose` lose to it, + * scoped classes win). Uses the shared terminal-frame + copy-button styling + * and a tab-trigger/tab-panel pattern. Tokens from global.css only. + * + * Inline `` inside blurbs/notes is injected via set:html, so its styling + * lives under `:global(code)` rules below. + */ + +type Method = { + id: string; + label: string; + recommended?: boolean; + command: string; + blurb: string; + upgrade?: string; + uninstall: string; + notes: string[]; +}; + +const methods: Method[] = [ + { + id: "homebrew", + label: "Homebrew", + recommended: true, + command: "brew install Makisuo/tap/maple", + blurb: + "Homebrew downloads the matching release bundle, verifies its checksum, installs maple and libchdb together, and links maple onto your PATH.", + upgrade: "brew upgrade maple", + uninstall: "brew uninstall maple", + notes: [ + "Homebrew-managed installs block maple update so the package manager stays in charge.", + "If Homebrew asks you to trust the tap, run brew trust Makisuo/tap once and retry the install.", + ], + }, + { + id: "script", + label: "Install script", + command: "curl -fsSL https://maple.dev/cli/install | sh", + blurb: + "The script is scripts/install.sh — read it first. It detects your OS/arch, downloads the matching bundle from the latest GitHub release, verifies its checksum, installs maple + libchdb into ~/.maple/bin, clears the macOS Gatekeeper quarantine, and symlinks maple onto your PATH.", + uninstall: "curl -fsSL https://maple.dev/cli/uninstall | sh", + notes: [ + "Your ~/.maple/data is kept on uninstall.", + "Migrating to Homebrew later? Remove the old PATH symlink so your shell resolves Homebrew's maple.", + ], + }, +]; + +const defaultId = methods[0].id; + +// simpleicons.org — Homebrew (fill), monochrome via currentColor. +const HOMEBREW_PATH = + "M7.938 0a.214.214 0 0 0-.206.156c-.316 1.104.179 2.15.838 2.935.153.181.313.347.476.501a2.039 2.039 0 0 0-.665.02c-1.184.233-2.193.985-2.74 2.532a3.893 3.893 0 0 0-.2 1.466 1.565 1.565 0 0 0-1.156 1.504 1.59 1.59 0 0 0 1.227 1.541l.026 12.046c0 .195.1.377.264.482a.214.214 0 0 0 .008.005c.537.31 2.047.812 5.21.812 3.238 0 4.7-.678 5.181-1.04a.214.214 0 0 0 .008-.007.571.571 0 0 0 .206-.439c.002-.344.002-1.136.002-1.604a.143.143 0 0 1 .147-.144c.397.006.869.006 1.318.005a1.826 1.826 0 0 0 1.832-1.825v-5.804a1.826 1.826 0 0 0-1.825-1.826H16.56a.14.14 0 0 1-.143-.144V10.6h.007v-.001a1.573 1.573 0 0 0 1.356-1.556c0-.816-.627-1.489-1.424-1.563-.025-1.438-.437-2.126-.736-2.58a.214.214 0 0 0-.005-.007c-.364-.51-1.193-1.282-2.275-1.316-.503-.016-.842.124-1.125.254-.217.1-.42.177-.67.22.002-1.286.945-1.981.945-1.981a.214.214 0 0 0 .05-.298s-.087-.122-.21-.26c-.121-.136-.269-.294-.47-.378a.214.214 0 0 0-.079-.017.214.214 0 0 0-.145.055 4.308 4.308 0 0 0-.875 1.101 3.42 3.42 0 0 0-.133.273 3.497 3.497 0 0 0-.381-.846C9.794.978 9.063.436 8.017.016A.214.214 0 0 0 7.939 0zm.156.524c.85.378 1.43.83 1.79 1.403.274.438.426.962.484 1.584a3.07 3.07 0 0 0-.012.462 6.897 6.897 0 0 1-.168-.052 5.487 5.487 0 0 1-1.29-1.106c-.551-.657-.935-1.46-.804-2.291zM11.8 1.618c.07.054.141.101.212.18.034.039.032.04.058.073-.332.308-1.07 1.144-.952 2.453a.214.214 0 0 0 .222.195c.469-.017.782-.172 1.056-.299.273-.126.508-.228.931-.214.875.027 1.639.715 1.939 1.134.295.449.65 1 .663 2.36a1.66 1.66 0 0 0-.41.142 1.938 1.938 0 0 0-1.77-1.16 1.94 1.94 0 0 0-1.87 1.448 1.783 1.783 0 0 0-1.356-.64c-.484 0-.91.205-1.233.517a1.873 1.873 0 0 0-1.85-1.625c-.649 0-1.218.335-1.552.84a3.1 3.1 0 0 1 .157-.735c.51-1.437 1.355-2.045 2.42-2.254.367-.073.664-.011.99.095.325.106.671.262 1.094.342a.214.214 0 0 0 .252-.245c-.112-.67.073-1.266.336-1.744a3.71 3.71 0 0 1 .663-.863zM7.44 6.611a1.442 1.442 0 0 1 1.363 1.925.214.214 0 0 0 .168.283h.005a.214.214 0 0 0 .238-.146 1.373 1.373 0 0 1 2.613-.01.214.214 0 0 0 .417-.09 1.509 1.509 0 0 1 1.504-1.664c.678 0 1.249.445 1.442 1.056a.214.214 0 0 0 .259.143l.15-.04a.214.214 0 0 0 .051-.02 1.139 1.139 0 0 1 1.702.995 1.14 1.14 0 0 1-.985 1.131.214.214 0 0 0-.001 0 2.215 2.215 0 0 0-.485.126 10.65 10.65 0 0 1-1.176.365.214.214 0 0 0-.162.186 1.276 1.276 0 0 1-.146.478 2.07 2.07 0 0 0-.239 1.111l.001.151a.438.438 0 0 1-.16.36.665.665 0 0 1-.43.14.586.586 0 0 1-.588-.59.803.803 0 0 0-.38-.681.214.214 0 0 0-.002-.002c-.24-.145-.43-.37-.532-.636a.214.214 0 0 0-.207-.138 19.469 19.469 0 0 1-5.37-.6l-.003-.002a9.007 9.007 0 0 0-.838-.194h.003a1.16 1.16 0 0 1-.937-1.134c0-.619.488-1.118 1.101-1.14a.214.214 0 0 0 .204-.176 1.443 1.443 0 0 1 1.42-1.187zm8.549 4.106v.455c0 .314.259.573.572.573h1.329a1.397 1.397 0 0 1 1.397 1.397v5.804a1.396 1.396 0 0 1-1.402 1.396.214.214 0 0 0-.002 0c-.448.002-.918 0-1.31-.005a.573.573 0 0 0-.584.573c0 .468 0 1.262-.002 1.603a.214.214 0 0 0 0 .001c0 .042-.019.08-.05.107-.346.26-1.75.95-4.915.95-3.107 0-4.587-.52-4.99-.752a.143.143 0 0 1-.065-.118l-.025-11.955c.145.033.288.07.431.11a.214.214 0 0 0 .003 0c.115.031.246.064.383.097v10.37c0 .129.069.247.18.31.453.217 1.767.732 4.071.732 2.32 0 3.595-.626 4.022-.884a.357.357 0 0 0 .164-.3l.001-10.21c.267-.075.531-.158.792-.254zm-7.99.894a.493.493 0 0 1 .494.493v8.578a.493.493 0 0 1-.493.493.493.493 0 0 1-.494-.493v-8.578A.493.493 0 0 1 8 11.611zm8.652 1.14a.663.663 0 0 0-.662.662v5.208a.663.663 0 0 0 .662.662h1.14a.663.663 0 0 0 .662-.662v-5.209a.663.663 0 0 0-.662-.662zm0 .428h1.14a.233.233 0 0 1 .233.233v5.21a.233.233 0 0 1-.233.232h-1.14a.233.233 0 0 1-.233-.233v-5.209a.233.233 0 0 1 .233-.233z"; +--- + +
+
+ { + methods.map((m) => ( + + )) + } +
+ + { + methods.map((m) => ( + + )) + } +
+ + + + diff --git a/apps/local-landing/src/components/local/LocalCommandGrid.astro b/apps/local-landing/src/components/local/LocalCommandGrid.astro new file mode 100644 index 000000000..b9728ad19 --- /dev/null +++ b/apps/local-landing/src/components/local/LocalCommandGrid.astro @@ -0,0 +1,195 @@ +--- +/** + * LocalCommandGrid — the query surface of the `maple` CLI, grouped by category. + * Each card header links to the matching section of the CLI reference doc + * (slugs must stay in sync with the H2 headings in local-mode/cli-reference.md). + */ +const REFERENCE = "/docs/reference/cli"; + +const groups: Array<{ + name: string; + anchor: string; + commands: Array<{ cmd: string; desc: string }>; +}> = [ + { + name: "services", + anchor: "services", + commands: [ + { cmd: "maple services", desc: "throughput, error rate, P95 per service" }, + { cmd: "maple diagnose ", desc: "health, top errors, recent traces & logs" }, + { cmd: "maple service-map", desc: "dependency edges with call & error rates" }, + { cmd: "maple top-ops ", desc: "operations ranked by a metric" }, + ], + }, + { + name: "traces", + anchor: "traces", + commands: [ + { cmd: "maple traces", desc: "search spans by service, duration, error" }, + { cmd: "maple trace ", desc: "full span tree + correlated logs" }, + { cmd: "maple slow-traces", desc: "the slowest traces with duration stats" }, + ], + }, + { + name: "errors", + anchor: "errors", + commands: [ + { cmd: "maple errors", desc: "error groups by fingerprint" }, + { cmd: "maple error ", desc: "one group: sample traces + timeseries" }, + ], + }, + { + name: "logs", + anchor: "logs", + commands: [ + { cmd: "maple logs", desc: "search by service, severity, text, trace" }, + { cmd: "maple log-patterns", desc: "cluster logs into noisy templates" }, + ], + }, + { + name: "analytics", + anchor: "analytics", + commands: [ + { cmd: "maple timeseries", desc: "time-bucketed latency, error rate, apdex" }, + { cmd: "maple breakdown", desc: "top-N by service / span / status / method" }, + { cmd: "maple compare", desc: "two windows side by side (regressions)" }, + ], + }, + { + name: "metrics & sql", + anchor: "metrics-and-raw-sql", + commands: [ + { cmd: "maple metrics", desc: "list available metrics" }, + { cmd: "maple attributes keys", desc: "discover attribute keys & values" }, + { cmd: 'maple query ""', desc: "raw ClickHouse SQL (local only)" }, + ], + }, +]; +--- + + + + diff --git a/apps/local-landing/src/components/local/LocalEyebrow.astro b/apps/local-landing/src/components/local/LocalEyebrow.astro new file mode 100644 index 000000000..76e9d5184 --- /dev/null +++ b/apps/local-landing/src/components/local/LocalEyebrow.astro @@ -0,0 +1,51 @@ +--- +/** + * LocalEyebrow — the section-index label used across the /local page. + * + * With an `index` it renders a mono number + an accent rule + the label + * (e.g. `01 ──── WHY LOCAL`), giving the page an editorial, dev-tool spine. + * Without an `index` it degrades to a plain uppercase sub-label, matching the + * existing eyebrow style for nested UI-view headings. + */ +interface Props { + index?: string; + label: string; +} +const { index, label } = Astro.props; +--- + +
+ {index ? {index} : null} + {index ? : null} + {label} +
+ + diff --git a/apps/local-landing/src/components/local/LocalLoopback.astro b/apps/local-landing/src/components/local/LocalLoopback.astro new file mode 100644 index 000000000..8ce6f8d64 --- /dev/null +++ b/apps/local-landing/src/components/local/LocalLoopback.astro @@ -0,0 +1,277 @@ +--- +/** + * LocalLoopback — the /local page's signature data-flow diagram. + * + * The flow is drawn as a closed loop: telemetry moves forward through the + * pipeline along marching-ants wires, + * then an animated return rail carries it back — making "nothing leaves + * 127.0.0.1" literal. Reuses global tokens + the service-map dash-flow cadence. + */ +const nodes = [ + { label: "OTLP exporter", sub: "your app / SDK" }, + { label: "POST /v1/*", sub: "ingest · :4318" }, + { label: "chDB store", sub: "~/.maple/data" }, + { label: "POST /local/query", sub: "query API" }, + { label: "dashboard · CLI", sub: "read it back" }, +]; +--- + +
+ + +
    + {nodes.map((n, i) => ( + +
  1. + + {n.label} + {n.sub} +
  2. + {i < nodes.length - 1 ?
    + ))} +
+ + + +
+ One process owns the chDB connection; short-lived CLI commands and the dashboard read it back over + the same /local/query HTTP contract — a complete loop that never leaves loopback. +
+
+ + diff --git a/apps/local-landing/src/components/local/LocalProductHero.astro b/apps/local-landing/src/components/local/LocalProductHero.astro new file mode 100644 index 000000000..64c6e7b0b --- /dev/null +++ b/apps/local-landing/src/components/local/LocalProductHero.astro @@ -0,0 +1,510 @@ +--- +/** + * LocalProductHero — marketing-scale hero for the /local product page. + * + * Leads with a headline + install CTA on the left and a *streaming* + * `maple start` banner on the right — the banner output + * reveals line-by-line on load, the single most on-theme moment for a local + * CLI product. Atmosphere is built from global tokens only: an amber glow + * behind the terminal, a fine grain overlay, and a staggered entrance cascade. + */ +const INSTALL = "brew install Makisuo/tap/maple"; + +// Tidied reproduction of the startup banner from apps/cli/src/commands/server.ts. +const banner: Array<{ key?: string; value: string; tone?: "amber" | "green" | "dim" }> = [ + { value: "🍁 maple · local mode", tone: "amber" }, + { value: "● listening on http://127.0.0.1:4318", tone: "green" }, + { value: "" }, + { key: "OTLP/HTTP", value: "POST /v1/{traces,logs,metrics}" }, + { key: "query", value: "POST /local/query" }, + { key: "dashboard", value: "https://local.maple.dev", tone: "amber" }, + { key: "data", value: "~/.maple/data" }, + { key: "pid", value: "48213 · stop with `maple stop`", tone: "dim" }, +]; + +const stats = [ + { cap: "port", val: ":4318" }, + { cap: "store", val: "chDB" }, + { cap: "signals", val: "traces · logs · metrics" }, + { cap: "deps", val: "none" }, +]; +--- + +
+ + + + + + + +
+
+ +
+
+ + + Maple Local + +
+ +

+ Observability that runs on localhost +

+ +

+ One Bun-compiled binary — OTLP ingest, an embedded ClickHouse, a query API, and the + dashboard — running on 127.0.0.1. A complete local + OpenTelemetry tool, with no cloud, no account, and no auth. +

+ + +
+ + {INSTALL} + +
+ + + +
+ + +
+
+
+ + ~/your-app + local +
+ +
+
+ + maple start +
+ +
{
+							banner.map((line, i) => (
+								
+ {line.key ? {line.key} : null} + {line.value} +
+ )) + }
+
+
+ +
    + {stats.map((s, i) => ( +
  • + {s.cap} + {s.val} +
  • + ))} +
+
+
+
+
+ + + + diff --git a/apps/local-landing/src/content.config.ts b/apps/local-landing/src/content.config.ts new file mode 100644 index 000000000..1512e22fd --- /dev/null +++ b/apps/local-landing/src/content.config.ts @@ -0,0 +1,15 @@ +import { defineCollection, z } from "astro:content" +import { glob } from "astro/loaders" + +const docs = defineCollection({ + loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/docs" }), + schema: z.object({ + title: z.string(), + description: z.string(), + group: z.string(), + order: z.number().default(0), + draft: z.boolean().default(false), + }), +}) + +export const collections = { docs } diff --git a/apps/local-landing/src/content/docs/getting-started/install.md b/apps/local-landing/src/content/docs/getting-started/install.md new file mode 100644 index 000000000..b56c54a79 --- /dev/null +++ b/apps/local-landing/src/content/docs/getting-started/install.md @@ -0,0 +1,50 @@ +--- +title: "Install" +description: "Install the maple binary with Homebrew or the curl installer — two files, one command, on macOS and Linux." +group: "Getting Started" +order: 1 +--- + +Maple Local ships as a relocatable **2-file bundle**: the `maple` binary (CLI + OTLP ingest + query server + embedded dashboard) and `libchdb` (the embedded ClickHouse engine, ~320 MB). Both installers fetch it from the latest [GitHub release](https://github.com/Makisuo/maple/releases) and verify its checksum. + +Supported platforms: **macOS (Apple Silicon)** and **Linux (x86_64 & arm64)**. On Windows, use WSL2 and follow the Linux path. + +## Homebrew (recommended) + +```bash +brew install Makisuo/tap/maple +``` + +Homebrew downloads the matching release bundle, verifies its checksum, installs `maple` and `libchdb.so` together in the Homebrew Cellar, and links `maple` onto your PATH. If Homebrew asks you to trust the third-party tap, run `brew trust Makisuo/tap` once and retry the install. + +## Curl installer + +```bash +curl -fsSL https://maple.dev/cli/install | sh +``` + +The installer detects your OS/arch, downloads the matching bundle from the latest GitHub release, verifies its checksum, installs the two files into `~/.maple/bin`, clears the macOS Gatekeeper quarantine, and symlinks `maple` onto your PATH. + +> Prefer to read before piping to a shell? The script is [`scripts/install.sh`](https://github.com/Makisuo/maple/blob/main/scripts/install.sh) in the repo, and you can always download a release bundle by hand from [GitHub Releases](https://github.com/Makisuo/maple/releases). + +Environment overrides for the installer: + +| Variable | Effect | +| --- | --- | +| `MAPLE_VERSION` | Pin a release tag instead of latest | +| `MAPLE_INSTALL_DIR` | Bundle location (default `~/.maple/bin`) | +| `MAPLE_BIN_DIR` | Where the PATH symlink goes | +| `MAPLE_SKIP_CHECKSUM=1` | Skip SHA-256 verification (air-gapped mirrors only; not recommended) | + +## Verify + +```bash +maple --version +maple start +``` + +`maple start` prints a banner with the OTLP endpoint (`:4318`) and the dashboard URL. Continue with the [Quickstart](/docs/getting-started/quickstart). + +## Manual download + +Every release on [GitHub Releases](https://github.com/Makisuo/maple/releases) contains per-platform bundles with a `.sha256` beside each. Keep `maple` and `libchdb` **in the same directory** — at runtime `maple` loads the sibling `libchdb` relative to its own path, so no `LD_LIBRARY_PATH` tricks are needed. diff --git a/apps/local-landing/src/content/docs/getting-started/quickstart.md b/apps/local-landing/src/content/docs/getting-started/quickstart.md new file mode 100644 index 000000000..2c1fe56c2 --- /dev/null +++ b/apps/local-landing/src/content/docs/getting-started/quickstart.md @@ -0,0 +1,50 @@ +--- +title: "Quickstart" +description: "Start the server, point an OTLP exporter at localhost, and open the dashboard — traces in under a minute." +group: "Getting Started" +order: 2 +--- + +Maple Local is the fastest way to look at OpenTelemetry data — point any OTLP exporter at `localhost`, open the dashboard, and explore traces, logs, and metrics with no account and nothing to deploy. Everything is single-tenant and stays on your machine. + +## 1. Start the server + +```bash +maple start # OTLP ingest + embedded ClickHouse + query API on :4318 +maple start --offline # …serve the UI bundled in the binary (no internet, no prompts) +maple start -d # …or detached; logs to ~/.maple/maple.log, stop with `maple stop` +``` + +`maple start` is the long-lived process: it owns the embedded ClickHouse (chDB) connection and hosts OTLP/HTTP ingest, the `/local/query` API, and (with `--offline`) the dashboard — all on one loopback port. Data persists in `~/.maple/data` between runs. + +Common flags: `--port` (default `4318`), `--data-dir` (default `~/.maple/data`), `--offline`, `--background`/`-d`, and `--reset` to wipe an incompatible store before starting. See the [CLI reference](/docs/reference/cli#server-commands) for the full list. + +## 2. Send telemetry + +The server speaks OTLP/HTTP on `POST /v1/{traces,logs,metrics}` (protobuf or JSON, gzip optional) — the same protocol every OpenTelemetry SDK already exports. Point your app at it; no auth header is needed: + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:4318" +export OTEL_SERVICE_NAME="my-service" +``` + +Most exporters default to protobuf and work out of the box. For language-specific setup, see [Sending data](/docs/sending-data/overview). + +## 3. Open the dashboard + +By default `maple start` points you at the auto-updating dashboard hosted at `local.maple.dev`, which talks back to your binary on loopback (the startup banner prints a link with the bound `?port=`). Because that page is a public origin reaching a local server, Chrome may show a one-time "access devices on your local network" prompt. + +Pass `--offline` to serve the dashboard bundled inside the binary from `127.0.0.1` instead: same-origin, no prompt, and it works with no internet. See [Offline mode](/docs/operations/offline-mode). The banner always prints the right URL for the mode you chose. + +## 4. Query from the terminal + +The same binary is also a query CLI. Every command runs against the running server and prints JSON by default (add `--format table` for an aligned table, or `--debug` to see the compiled SQL on stderr): + +```bash +maple services # active services at a glance +maple traces --service api --since 1h # recent spans for one service +maple errors --since 24h # error groups by fingerprint +maple query "SELECT count() FROM traces" +``` + +Most query flags are shared: `--since` (e.g. `30m`, `1h`, `24h`, `7d`) or absolute `--start`/`--end`, `--service`/`-s`, `--env`/`-e`, and `--limit`/`-n`. The full surface lives in the [CLI reference](/docs/reference/cli). diff --git a/apps/local-landing/src/content/docs/operations/data-and-storage.md b/apps/local-landing/src/content/docs/operations/data-and-storage.md new file mode 100644 index 000000000..9cbd2d1b7 --- /dev/null +++ b/apps/local-landing/src/content/docs/operations/data-and-storage.md @@ -0,0 +1,41 @@ +--- +title: "Data & storage" +description: "How the embedded ClickHouse store works, where data lives on disk, and how recovery behaves after an unclean shutdown." +group: "Operations" +order: 2 +--- + +Maple Local stores telemetry in an **embedded ClickHouse** — [chDB](https://github.com/chdb-io/chdb), the in-process ClickHouse engine — loaded directly into the `maple` process via FFI. No database server, no container: one process owns everything. + +## One process, one connection + +chDB allows exactly one connection per process, so the long-lived `maple start` process owns it. Short-lived query commands (`maple traces`, `maple query`, …) don't open the store themselves — they reach the running server over HTTP on loopback. That's why query commands need `maple start` running. + +Everything is single-tenant: every row is written under `org_id = "local"`, and every compiled query filters on it. + +## Where data lives + +| Path | Contents | +| --- | --- | +| `~/.maple/data` | The ClickHouse store — traces, logs, metrics (persists between runs) | +| `~/.maple/config.json` | CLI config: pinned mode, tokens, update-check cache | +| `~/.maple/maple.log` | Server log when started detached (`maple start -d`) | +| `~/.maple/bin` | Binary + `libchdb` when installed via the curl installer | + +Override the store location per server with `maple start --data-dir `. + +## Wiping the store + +```bash +maple reset # wipe the store (server stopped) +maple start --reset # wipe and start fresh in one step +``` + +## Store versioning & crash recovery + +Two sentinel files beside the store guard its integrity: + +- **`maple-store-version.json`** records which chDB build bootstrapped the store. A different chDB build can't be trusted to reload another's persisted materialized views (it could crash the engine natively), so after an upgrade that changes chDB, `maple start` refuses up front and tells you to run `maple start --reset`. +- **`maple-store-open`** is a clean-shutdown sentinel. It's written right after the store opens and removed as the last step of a clean close. If `maple start` finds it still present over a populated store, the previous server died without closing cleanly and the store may be inconsistent — rather than risk a native crash, the server **auto-wipes the store and bootstraps fresh**, printing a warning. + +The practical takeaway: local telemetry is scratch data. It survives normal restarts fine, but it is **not recoverable** after an unclean kill of the server — re-ingest to repopulate. Anything you need durably belongs in a hosted workspace. diff --git a/apps/local-landing/src/content/docs/operations/offline-mode.md b/apps/local-landing/src/content/docs/operations/offline-mode.md new file mode 100644 index 000000000..09613b7b4 --- /dev/null +++ b/apps/local-landing/src/content/docs/operations/offline-mode.md @@ -0,0 +1,30 @@ +--- +title: "Offline mode" +description: "Where the dashboard comes from, why the browser may prompt, and how --offline serves everything from 127.0.0.1." +group: "Operations" +order: 1 +--- + +The dashboard SPA is a single build served two ways, and it decides which `/local/query` base URL to use from `window.location`. + +## Default: the hosted dashboard + +`maple start` points you at the dashboard deployed to `local.maple.dev`. The page is static — it talks back to **your** binary on loopback, so your telemetry never leaves the machine. Serving the UI from a public origin decouples UI updates from binary releases: dashboard fixes ship by deploying, no new binary needed. + +Because that page is a *public* origin, its queries to `http://127.0.0.1:/local/query` are a **public → loopback** request, which trips the browser's Private Network Access gate. The server answers the preflight correctly (`Access-Control-Allow-Private-Network: true`), but recent Chrome may still show a one-time "wants to access devices on your local network" prompt; Safari and Firefox differ. + +The startup banner encodes the bound port as `?port=`, so links keep working on non-default ports. `MAPLE_LOCAL_UI_URL` overrides the default UI origin. + +## `--offline`: everything from the binary + +```bash +maple start --offline +``` + +The binary serves the dashboard bundled inside it from `127.0.0.1`, so queries are same-origin: no CORS, no Private Network Access, no permission prompt — and it works with **no internet connection at all**. This is the recommended escape hatch whenever the default path hits a browser prompt, and the right mode for air-gapped machines. + +The bundled copy is baked in at release time, so it can trail the hosted dashboard slightly; upgrade the binary to pick up UI changes in offline mode. + +## Which URL do I open? + +You don't need to remember — the `maple start` banner always prints the right URL for the mode you chose. diff --git a/apps/local-landing/src/content/docs/operations/updating-uninstall.md b/apps/local-landing/src/content/docs/operations/updating-uninstall.md new file mode 100644 index 000000000..53355b71e --- /dev/null +++ b/apps/local-landing/src/content/docs/operations/updating-uninstall.md @@ -0,0 +1,44 @@ +--- +title: "Updating & uninstalling" +description: "Keep the binary current with brew upgrade or maple update, and remove it cleanly when you're done." +group: "Operations" +order: 3 +--- + +Update with the same tool you installed with. + +## Homebrew + +```bash +brew upgrade maple +brew uninstall maple +``` + +Homebrew installs are managed by Homebrew: the wrapper disables Maple's startup update check, and `maple update` exits with a reminder to use `brew upgrade maple`. + +## Curl-installer builds + +Manual-installer builds keep themselves current: + +- **Startup notice.** On any command, `maple` checks GitHub Releases for a newer version — at most **once per 24h** (cached in `~/.maple/config.json`, so every other run stays instant and offline). When a newer release exists it prints a one-line `update available` notice to stderr; it never changes behavior mid-run. The check is skipped for non-interactive shells (CI/pipes) and the `--version`/`--help`/`update` paths. Opt out entirely with `MAPLE_NO_UPDATE_CHECK=1`. +- **`maple update`** downloads the latest release bundle, verifies its SHA-256, and installs it in place with an atomic rename — safe even while the old binary is running (restart any running `maple start` afterward). + +```bash +maple update # install the latest release +maple update --check # report current vs. latest without installing +maple update --tag v0.6.0 # install (or downgrade to) a specific release +``` + +`maple update` and re-running `curl … | sh` fetch the same artifact, so they're interchangeable. + +### Uninstall + +```bash +curl -fsSL https://maple.dev/cli/uninstall | sh +``` + +The uninstaller removes the `maple` symlink and the `~/.maple/bin` bundle. Your data dir (`~/.maple/data`) is kept unless you confirm its removal when prompted. It honors the same `MAPLE_INSTALL_DIR` / `MAPLE_BIN_DIR` overrides as the installer. + +### Migrating to Homebrew + +If you switch from the curl installer to Homebrew, run the uninstaller first (or remove the old PATH symlink) so your shell resolves Homebrew's `maple`. diff --git a/apps/local-landing/src/content/docs/reference/cli.md b/apps/local-landing/src/content/docs/reference/cli.md new file mode 100644 index 000000000..d5271970c --- /dev/null +++ b/apps/local-landing/src/content/docs/reference/cli.md @@ -0,0 +1,338 @@ +--- +title: "CLI Reference" +description: "Every maple command, argument, and flag — plus the server endpoints, environment variables, and troubleshooting for local mode." +group: "Reference" +order: 1 +--- + +The `maple` binary is one CLI with two backends: a local server (`maple start`) and a remote workspace (`maple login`). Every query command runs against whichever is [resolved](#auth-and-configuration) for that invocation. Output is JSON by default — clean enough to pipe into `jq` or an agent. + +This page is the complete surface. For a guided walkthrough, start with [Quickstart](/docs/getting-started/quickstart). + +## Global flags + +These are accepted by every command (position-independent — `maple --local traces` and `maple traces --local` both work): + +| Flag | Description | +| ------------------------ | --------------------------------------------------------------------------------- | +| `--local` | Force local mode (requires a running `maple start`) | +| `--remote` | Force remote mode (requires `maple login`) | +| `--debug` | Print the compiled SQL and per-query timing to stderr (stdout stays clean JSON) | +| `--format ` | Output format; default `json`. `table` renders a flat row set as an aligned table | + +Most **query** commands also share a set of filter flags. Defaults and availability vary per command (listed below), but the shapes are consistent: + +| Flag | Alias | Default | Description | +| ------------------ | ----- | ------- | ------------------------------------------------------------ | +| `--since ` | | `6h` | Relative time range — `30m`, `1h`, `6h`, `24h`, `7d` | +| `--start