port mintlify to fumadocs

This commit is contained in:
miloschwartz
2026-09-25 15:31:51 -04:00
parent dc54fb1017
commit 63199a588c
373 changed files with 13533 additions and 4577 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.source
.env*
!.env.example
Dockerfile
.dockerignore
+37
View File
@@ -0,0 +1,37 @@
# Copy to .env.local (dev) or pass as container env vars (prod).
# Public URL of the site, used in llms.txt links, canonical URLs and OG metadata
NEXT_PUBLIC_SITE_URL=https://docs.pangolin.net
# ---------------------------------------------------------------------------
# AI assistant (bring your own key). Set ONE provider. Keys stay on the server.
# ---------------------------------------------------------------------------
# Optional: anthropic | openai | google | openai-compatible
# (inferred from whichever key below is set)
# AI_PROVIDER=anthropic
# Optional: model id for the provider (defaults: openai=gpt-5.6-luna,
# anthropic=claude-opus-5, google=gemini-2.5-pro; required for openai-compatible)
# AI_MODEL=gpt-5.6-luna
# OpenAI
# OPENAI_API_KEY=
# Anthropic
# ANTHROPIC_API_KEY=
# Google Gemini
# GOOGLE_GENERATIVE_AI_API_KEY=
# Any OpenAI-compatible endpoint: OpenRouter, Ollama, vLLM, LiteLLM, or a
# Pangolin AI Gateway resource
# AI_BASE_URL=https://openrouter.ai/api/v1
# AI_API_KEY=
# AI_MODEL=anthropic/claude-opus-5
# Requests per minute per IP to /api/chat (0 disables the limiter)
# AI_RATE_LIMIT_PER_MINUTE=10
# Set to 1 to disable PostHog / Rybbit / Reo in production builds
# NEXT_PUBLIC_DISABLE_ANALYTICS=
+27
View File
@@ -1 +1,28 @@
# deps
/node_modules
# generated content
.source
# test & build
/coverage
/.next/
/out/
/build
*.tsbuildinfo
# misc
.DS_Store
*.pem
/.pnp
.pnp.js
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# others
.env*.local
.vercel
next-env.d.ts
# local env
.env
-5
View File
@@ -1,5 +0,0 @@
{
"cSpell.words": [
"nessicary"
]
}
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+28
View File
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:24-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_SITE_URL=https://docs.pangolin.net
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/.next/standalone ./
COPY --from=build --chown=app:app /app/.next/static ./.next/static
COPY --from=build --chown=app:app /app/public ./public
USER app
EXPOSE 3000
CMD ["node", "server.js"]
+56 -18
View File
@@ -1,35 +1,73 @@
# Pangolin Documentation
This documentation site is built using [Mintlify](https://mintlify.com). If you'd like to contribute to the documentation or run it locally for development, follow the instructions below.
The Pangolin docs site, built with [Fumadocs](https://fumadocs.dev) on Next.js and fully self-hostable. It replaces the old Mintlify site (`docs-v2`) and keeps the same URLs.
### Local Development
## Features
Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally:
- **Same URLs as Mintlify.** Pages are served from the site root (`/manage/sites/install-site`). The old Mintlify redirects are in `lib/redirects.json`.
- **Full-text search** (Orama, runs in-process) with <kbd>⌘</kbd><kbd>K</kbd>, served from `/api/search`.
- **AI assistant**: a sticky "Ask a question…" bar at the bottom of every page (<kbd>⌘</kbd><kbd>I</kbd>) that opens a chat panel. An agent that searches and reads the docs and cites the pages it used. It runs on your own API key with Anthropic, OpenAI, Google, or any OpenAI-compatible endpoint.
- **LLM-friendly output:**
- `/llms.txt`: an index following the sidebar structure
- `/llms-full.txt`: every page in one file
- `/<any-page>.md`: the Markdown for a single page. Requests sent with `Accept: text/markdown` get the same Markdown.
- Each page has "Copy Markdown" and "Open in ChatGPT / Claude / …" actions.
- Generated Open Graph images for every page (`/og/...`).
## Development
```bash
npm i -g mint
npm install
cp .env.example .env.local # optional: add an AI key
npm run dev # http://localhost:3000
```
Run the following command at the root of your documentation, where your `docs.json` is located:
Other scripts: `npm run build`, `npm start`, `npm run types:check`.
## Writing docs
- Pages live in `content/docs/**/*.mdx`. The URL is the file path.
- The **sidebar** is defined in `lib/navigation.json`, using the same group/pages shape as Mintlify's `docs.json`. Mintlify groups don't map to folders, so this file replaces per-folder `meta.json`. A page that isn't listed there is still published; it just doesn't appear in the sidebar.
- The Mintlify components used by the content keep working with the same names and props: `Note`, `Info`, `Tip`, `Warning`, `Check`, `Card`, `CardGroup`, `Columns`, `Steps`/`Step`, `Tabs`/`Tab`, `Accordion`/`AccordionGroup`, `Expandable`, `ResponseField`, `Frame`, `Update`. They're implemented in `components/mintlify.tsx`, and the icon names are mapped from Font Awesome in `components/icons.tsx`.
- Code blocks: ` ```yaml title="config.yml" {3-5}` sets a title and highlights lines 3–5. To make tabbed code, put consecutive blocks with `tab="Name"` in a row (this replaces `<CodeGroup>`).
- Shared snippets live in `content/snippets/` and are pulled in with `<include>../../snippets/file.mdx</include>`.
- Images go in `public/images/` and are referenced as `/images/...`.
## AI assistant configuration
Everything is set with server-side environment variables, so keys are never sent to the browser. See `.env.example`.
| Variable | Purpose |
| --- | --- |
| `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GOOGLE_GENERATIVE_AI_API_KEY` | Set one of these to choose that provider |
| `AI_BASE_URL` + `AI_API_KEY` | Any OpenAI-compatible API (OpenRouter, Ollama, vLLM, LiteLLM, a Pangolin AI Gateway resource) |
| `AI_PROVIDER` | Force a provider when more than one key is set |
| `AI_MODEL` | Model ID. Defaults: `gpt-5.6-luna`, `claude-opus-5`, `gemini-2.5-pro`. Required for OpenAI-compatible endpoints |
| `AI_RATE_LIMIT_PER_MINUTE` | Per-IP limit on `/api/chat` (default 10, `0` turns it off). The limit is kept in memory for each server instance |
If no key is set, the rest of the site works normally and the assistant returns a clear "not configured" message.
How it works (`lib/ai/`): the system prompt contains an index of every page, and the model has two tools. `search_docs` queries the same Orama index as the search bar, and `read_page` returns a page's full Markdown. The route is `app/api/chat/route.ts`, and the UI is in `components/ai/`.
## Self-hosting
```bash
mint dev
docker compose up -d --build # reads .env if present, serves on :3000
```
View your local preview at `http://localhost:3000`.
or without Compose:
### Publishing Changes
```bash
docker build -t pangolin-docs --build-arg NEXT_PUBLIC_SITE_URL=https://docs.pangolin.net .
docker run -p 3000:3000 -e OPENAI_API_KEY=... pangolin-docs
```
Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch.
The build uses Next.js `output: 'standalone'`, so the image only needs Node to run. Pages, llms files, and OG images are prerendered. Only `/api/search` and `/api/chat` run at request time.
### Need Help?
## Syncing from the Mintlify repo
#### Troubleshooting
`scripts/migrate-from-mintlify.py` copies content, images, navigation, and redirects from `docs-v2` and rewrites the Mintlify-only syntax (code fence titles, `highlight=`, `<CodeGroup>`, snippet imports). It overwrites `content/`, `public/images`, `lib/navigation.json`, and `lib/redirects.json`, so only use it while `docs-v2` is still the source of truth:
- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI.
- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`.
#### Resources
- [Mintlify documentation](https://mintlify.com/docs)
- [Mintlify community](https://mintlify.com/community)
- [Pangolin GitHub Repository](https://github.com/fosrl/pangolin)
```bash
npm run migrate # = python3 scripts/migrate-from-mintlify.py ../docs-v2
```
-40
View File
@@ -1,40 +0,0 @@
---
title: "FIPS 140 and FedRAMP Considerations"
description: "How Pangolin's WireGuard-based tunnels fit into FIPS 140 and FedRAMP-governed environments."
---
<Note>
This page explains where Pangolin's encryption stands relative to FIPS 140 and FedRAMP requirements. It is not compliance or legal advice - work with your ISSO or compliance team to determine what applies to your specific system boundary.
</Note>
Every Pangolin tunnel - between a site connector and a node, or a client and a site - is a [WireGuard](/development/system-architecture#site-connectors) connection. WireGuard uses a fixed, modern cryptographic suite: Curve25519 for key exchange, ChaCha20-Poly1305 for encryption, and BLAKE2s for hashing. This cryptography is well-reviewed and considered strong, but "strong" and "FIPS 140-validated" are different things - validation refers specifically to a cryptographic module having gone through NIST's Cryptographic Module Validation Program (CMVP), and WireGuard's implementation has not gone through that process. As of this writing, Pangolin does not use a FIPS-validated module for its tunnel encryption.
## Where Pangolin can fit in a FedRAMP boundary
Not using a FIPS-validated module for the tunnel itself doesn't automatically disqualify Pangolin from a FedRAMP-authorized environment but it changes how you scope it:
- **Layer it, don't rely on it alone.** If your system already terminates FIPS-validated TLS or IPsec at its authorization boundary, a Pangolin tunnel can run as an additional encrypted layer inside or alongside that connection rather than being the control that has to satisfy FIPS validation on its own. FedRAMP guidance generally permits non-validated encryption in an inner layer when an outer layer already satisfies the relevant controls (commonly referenced as SC-8(1) and SC-28(1) for transmission and data-at-rest confidentiality).
- **Decide where the boundary sits.** Work with your ISSO to determine, per NIST SP 800-18 and the impact categorization in FIPS 199, whether Pangolin operates inside your system's authorization boundary as a component, or outside it as a supporting service that carries traffic but isn't part of the accredited system itself.
- **Treat it as a component, not the system of record.** Pangolin's [system architecture](/development/system-architecture) - control plane, nodes, connectors, and clients - is documented specifically so it can be mapped cleanly onto a system boundary diagram during this evaluation.
## Reducing what's outside your control
If keeping infrastructure inside an already-accredited boundary matters more than convenience, [self-hosting](/self-host/quick-install) is the relevant lever: the control plane and every node can run entirely on infrastructure you already operate and have assessed, rather than depending on Pangolin Cloud's shared infrastructure. [Enterprise Edition](/self-host/enterprise-edition) extends this with clustering, additional identity providers, and centralized logging for environments that need tighter operational control. If your organization needs a compliance package beyond what's documented here, [contact sales](mailto:sales@pangolin.net) to discuss your requirements directly.
<CardGroup cols={2}>
<Card title="System Architecture" icon="server" href="/development/system-architecture">
Map Pangolin's components onto your system boundary.
</Card>
<Card title="Self-Host Pangolin" icon="lock" href="/self-host/quick-install">
Keep the control plane and nodes on infrastructure you already operate.
</Card>
<Card title="Enterprise Edition" icon="building" href="/self-host/enterprise-edition">
Clustering, additional identity providers, and centralized logging.
</Card>
<Card title="Zero Trust Networking" icon="shield-halved" href="/about/security/zero-trust-networking">
How Pangolin's encryption and authorization model is structured.
</Card>
</CardGroup>
-85
View File
@@ -1,85 +0,0 @@
---
title: "Least-Privilege Access"
description: "Why scoping access down to what's actually needed matters, and how to enforce it with Pangolin's roles, policies, and approvals."
---
Every organization eventually asks the same question: does this person, device, or integration really need to reach everything it currently can? Least-privilege access is the practice of answering "no" by default - granting each identity only the specific resources required for its job, and nothing kept around "just in case." It's sometimes called minimal access or need-to-know access, and it's one of the few security practices that pays off whether or not you're ever attacked, because it also limits how much damage a mistake can do.
## The balance admins actually have to strike
Scope access too loosely and a single compromised account, leaked token, or careless click can reach far more than it should. Scope it too tightly and people spend their day filing access requests instead of working, while whoever approves those requests turns into a bottleneck for routine tasks. Neither extreme is sustainable.
The practical goal isn't "as little access as physically possible" - it's a narrow default combined with a fast, auditable path to widen access temporarily when a real need comes up. A contractor who needs staging access for two weeks should get staging access for two weeks, not permanent access "since we'll probably need them again."
## What tight scoping actually buys you
If an account is compromised, tight scoping means the attacker inherits only what that account could reach - not the whole organization. It also means fewer people hold standing access to any given system, so there are fewer unexpected changes and a more predictable environment overall. When an incident does happen, scoping by role lets you immediately narrow "who could have done this" to the handful of people who actually had the ability to, instead of auditing everyone.
There's an ownership benefit too: access mapped to job function makes it obvious who's responsible for what, instead of everyone having a bit of everything. Onboarding and offboarding get simpler, since assigning or removing a role beats auditing an individual's sprawling permission history one entry at a time. And a lot of compliance frameworks specifically expect access to be scoped to job function - this is one of the easier boxes to check when it's built in from the start rather than bolted on afterward.
## How narrow scope limits real attacks
Most breaches don't start with an attacker breaking into everything at once - they start with one foothold, then depend on that foothold being able to reach more than it should. Tight scoping blunts this at every step. An attacker trying to climb from a low-privileged account into an administrative one, or hijack a peer's similarly-scoped credentials, simply has less to climb into if roles are narrow to begin with. A single stolen credential goes less far, too: passwords get phished, reused, or leaked in unrelated breaches constantly, and if that credential only unlocks one role's resources, the damage is contained. Malware that hijacks a session or a device inherits whatever that session could reach and nothing more. And insider risk shrinks - a disgruntled or careless employee, whether acting deliberately or by mistake, can only affect the systems their role actually touches.
## Applying least privilege with Pangolin
Pangolin gives you the building blocks to enforce this without hand-managing individual permissions:
<Steps>
<Step title="Scope by role, not by person">
Group users into [roles](/manage/access-control/create-user#roles) - Operations, Contractor, Support, or any custom role you define - instead of granting access to individuals one at a time. Each [resource](/manage/resources/understanding-resources) specifies which roles can reach it, and a user's effective access is just the union of what their roles allow. Revoke a role and every resource it granted access to disappears with it.
</Step>
<Step title="Keep policies consistent across resources">
Rather than re-configuring authentication and access settings on every resource individually, define a [resource policy](/manage/resources/public/resource-policies) once and attach it wherever the same baseline should apply. This keeps a large number of resources from silently drifting out of sync with each other.
</Step>
<Step title="Narrow further with rules">
Layer in [access rules](/manage/access-control/rules) that allow, deny, or require authentication based on URL path, IP, CIDR range, country, or region - useful for exposing only the specific paths a role needs, or keeping traffic from regions you don't operate in out entirely. [Geo-blocking](/manage/geoblocking) and [ASN blocking](/manage/asnblocking) extend this to entire countries or networks, including known VPN, proxy, and datacenter ranges.
</Step>
<Step title="Gate new or unrecognized devices">
[Device approvals](/manage/access-control/approvals) require an administrator to review and approve any new device before it can connect, per role - useful for roles where you don't want valid credentials alone to be sufficient.
</Step>
<Step title="Force re-verification instead of trusting it forever">
[Session length](/manage/access-control/session-length) and [password rotation](/manage/access-control/password-rotation) policies make sure a credential earned once doesn't stay valid indefinitely. Pair this with [MFA](/manage/access-control/mfa) or hardware [security keys](/manage/access-control/security-keys) so a leaked password alone isn't enough to authenticate.
</Step>
<Step title="Review what actually happened">
[Admin action logs](/manage/analytics/action) give you an audit trail of configuration changes and administrative actions, so scoping decisions can be revisited based on evidence rather than guesswork.
</Step>
</Steps>
## A checklist worth revisiting periodically
- List what each role can currently reach, and confirm it still matches what that role's job requires.
- Strip standing administrative access from roles that only need it occasionally - grant it temporarily instead.
- Give administrators individual accounts rather than sharing one set of credentials.
- Require MFA or security keys, at minimum for any role with elevated access.
- Enforce session length and password rotation so old credentials don't quietly outlive their usefulness.
- Enable device approvals for roles where an unrecognized device connecting should never happen silently.
- Re-audit roles and their resource access on a recurring schedule, not just when something breaks.
- Use rules, geo-blocking, and ASN blocking to shrink who can even attempt to authenticate in the first place.
<CardGroup cols={2}>
<Card title="Users and Roles" icon="users" href="/manage/access-control/create-user">
Group users into roles and control who belongs to each one.
</Card>
<Card title="Resource Policies" icon="shield" href="/manage/resources/public/resource-policies">
Define authentication and access rules once, reuse them everywhere.
</Card>
<Card title="Rules" icon="filter" href="/manage/access-control/rules">
Allow, deny, or require auth based on path, IP, country, or region.
</Card>
<Card title="Device Approvals" icon="check" href="/manage/access-control/approvals">
Require admin sign-off before a new device can connect.
</Card>
</CardGroup>
-8
View File
@@ -1,8 +0,0 @@
---
title: "Pangolin Uses WireGuard"
description: "How Pangolin uses WireGuard to create secure, encrypted connections"
---
WireGuard is an open source network tunneling protocol for creating encrypted communication channels. It's designed to replace other VPN protocols, such as OpenVPN and IPsec, as a simpler and lighter-weight alternative. The protocol maintains concurrent connections with minimal overhead per session. Independent cryptographers have reviewed the WireGuard protocol, and security auditors have examined the code implementation, identifying and allowing for the correction of minor issues. For more about WireGuard's technical details, review their whitepaper.
Pangolin builds on top of WireGuard and adds additional components such as NAT traversal, relay fallback, and access control policies. Pangolin's implementation differs from standard WireGuard implementations in several ways. For example, where WireGuard provides encrypted tunnels between endpoints, Pangolin constructs a hub-and-spoke network topology with additional network services and authentication mechanisms. Pangolin uses the open source wireguard-go package running in userspace, which comes with Pangolin's Newt and Olm clients.
-70
View File
@@ -1,70 +0,0 @@
---
title: "Zero Trust Networking"
description: "Why Pangolin encrypts every connection and authorizes by identity rather than trusting the network it runs on."
---
Zero trust networking starts from an uncomfortable assumption: the network your traffic physically travels over cannot be trusted, even the parts you think of as "internal." Access should be decided by who and what is asking, not by which wire or subnet the request happens to arrive on.
## Why the old assumption stopped holding
For a long time, network security was designed like a building: a hardened perimeter - the firewall - kept threats out, and everything inside that boundary was implicitly trusted. VPNs extended that same trusted interior out to remote workers. It's sometimes described as a hard shell around a soft interior.
That model breaks down once you account for how internal networks actually get compromised. Well-documented breaches at major technology companies have shown attackers reaching deep into "internal" infrastructure once they cleared the perimeter. Add unencrypted internal DNS traffic and employees working from coffee shop Wi-Fi, and the idea that anything inside the firewall is automatically safe stops being credible. If the perimeter is the only thing enforcing security, one gap anywhere in it compromises everything behind it.
## What replaces trusting the wire
The fix is removing the network itself from the trust decision entirely. Every connection gets encrypted, always, regardless of whether the traffic is "internal" or "external" - there's no privileged segment where encryption gets dropped because it's assumed to be safe. Authorization is based on identity, not location: what a connection is allowed to reach depends on who or what established it, not which subnet it originated from. And compromise gets contained at the peer - if a device is breached, restricting which encrypted peers it's authorized to reach means the attacker can't simply pivot to whatever else happens to share that network segment.
Even if someone gains physical or logical access to the network itself, all they find is encrypted traffic between authorized peers - nothing to sniff, and no ability to move laterally without valid credentials of their own.
## How this shows up in Pangolin's architecture
Pangolin is built around this model rather than having it added on top. Every tunnel is encrypted by default, with no exceptions: site connectors and clients build their tunnels on WireGuard, coordinated by Pangolin's tunnel manager ([Gerbil](/development/system-architecture#nodes)) on the node side and the shared client stack ([Olm](/development/system-architecture#shared-client-stack-olm)) on the device side. There's no unencrypted "trusted internal" path anywhere in the data plane.
Site connectors are also deny-by-default proxies - installing a [site](/manage/sites/understanding-sites) doesn't expose anything on that network. Nothing is reachable until you explicitly define a [resource](/manage/resources/understanding-resources) and grant a role access to it; the network being reachable is never itself the authorization. Authentication happens per request, not per network: public resources are enforced through forward-auth middleware ([Badger](/development/system-architecture#nodes)) sitting in front of every request, and private resources are only reachable by clients that already hold a valid, role-scoped tunnel - being on the same LAN as a site connector grants nothing on its own.
Access follows the user rather than the connection point. [Roles](/manage/access-control/create-user#roles) determine which resources a client is authorized to reach, and that access control list travels with the identity whether they're connecting from the office, home, or a coffee shop.
## Rolling this out without a flag day
You don't need to tear out your existing perimeter to start moving toward this model - it's meant to be adopted incrementally, service by service:
<Steps>
<Step title="Wrap one sensitive service first">
Stand up a [site](/manage/sites/install-site) alongside your existing network and turn a single high-value internal service into a [private resource](/manage/resources/understanding-resources#private-resource-types). Traffic to it is now encrypted end-to-end and gated by role, independent of whatever perimeter controls already exist.
</Step>
<Step title="Verify access follows identity">
Confirm that only the intended roles can reach the new resource, and that removing a role actually removes access - not just that the tunnel works.
</Step>
<Step title="Repeat until the subnet is covered">
Move the rest of the hosts behind that network into resources one at a time. Once everything on that subnet is reachable exclusively through Pangolin, the broader network-level trust it used to rely on (a VPN route, a wide firewall allowlist) is no longer load-bearing.
</Step>
<Step title="Retire the old path and move to the next network">
Remove the legacy access route for that subnet, then repeat the process for the next site or location.
</Step>
</Steps>
## What you still have to trust
"Zero trust" doesn't mean trusting nothing - it means narrowing what you trust down to a small, deliberate set of components: your identity provider, and the policy engine that decides who's authorized to reach what. In Pangolin, that's the [control plane](/development/system-architecture#control-plane) - the server that authenticates users, evaluates roles against resources, and pushes the resulting access decisions out to every node, site, and client. Everything else - the physical network, the datacenter, the office Wi-Fi - is treated as untrusted by design, which is precisely the point.
<CardGroup cols={2}>
<Card title="How Pangolin Works" icon="diagram-project" href="/about/how-pangolin-works">
The product-level tour of sites, resources, and clients.
</Card>
<Card title="System Architecture" icon="server" href="/development/system-architecture">
A technical look at the control plane, nodes, connectors, and clients.
</Card>
<Card title="Understanding Sites" icon="plug" href="/manage/sites/understanding-sites">
How site connectors expose resources without opening inbound ports.
</Card>
<Card title="Least-Privilege Access" icon="shield-halved" href="/about/security/least-privilege-access">
Scoping who's authorized to reach what once identity is the boundary.
</Card>
</CardGroup>
+86
View File
@@ -0,0 +1,86 @@
import { source } from '@/lib/source';
import {
DocsBody,
DocsDescription,
DocsPage,
DocsTitle,
MarkdownCopyButton,
ViewOptionsPopover,
} from 'fumadocs-ui/layouts/notebook/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/components/mdx';
import type { Metadata } from 'next';
import { createRelativeLink } from 'fumadocs-ui/mdx';
import { getPageImageUrl, getPageMarkdownUrl, gitConfig, siteUrl } from '@/lib/shared';
import { AskAIAboutPage } from '@/components/ai/ask-page';
import { SiteFooter } from '@/components/site-footer';
import { AskBar } from '@/components/ai/ask-bar';
export default async function Page(props: PageProps<'/[[...slug]]'>) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
const markdownUrl = getPageMarkdownUrl(page).url;
return (
<DocsPage
toc={page.data.toc}
full={page.data.full}
tableOfContent={{ style: 'clerk' }}
// rendered after the previous / next page links
footer={{
children: (
<>
<SiteFooter />
<AskBar />
</>
),
}}
>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
<div className="flex flex-row flex-wrap gap-2 items-center border-b pb-6">
<MarkdownCopyButton markdownUrl={markdownUrl} />
<ViewOptionsPopover
markdownUrl={markdownUrl}
pageUrl={`${siteUrl}${page.url}`}
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/${page.path}`}
/>
<AskAIAboutPage title={page.data.title} />
</div>
<DocsBody>
<MDX
components={getMDXComponents({
// this allows you to link to other pages with relative file paths
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: PageProps<'/[[...slug]]'>): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const isHome = page.slugs.length === 0;
return {
title: isHome ? { absolute: `${page.data.title} - Pangolin Docs` } : page.data.title,
description: page.data.description,
alternates: {
canonical: page.url,
types: { 'text/markdown': getPageMarkdownUrl(page).url },
},
openGraph: {
images: isHome ? '/images/home-social-graph.png' : getPageImageUrl(page).url,
},
};
}
+65
View File
@@ -0,0 +1,65 @@
import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
import type { LayoutTab } from 'fumadocs-ui/layouts/shared';
import { Banner } from 'fumadocs-ui/components/banner';
import Link from 'fumadocs-core/link';
import { baseOptions } from '@/lib/layout.shared';
import { getNavigationTree, navTabs } from '@/lib/navigation';
import { banner } from '@/lib/shared';
import { source } from '@/lib/source';
import type { CSSProperties } from 'react';
import { AISearch, AISearchPanel } from '@/components/ai/search';
import { headerSearchSlot } from '@/components/header-search';
import { SidebarAnchors } from '@/components/sidebar-anchors';
const tabs: LayoutTab[] = [
// every docs page counts as the "Documentation" tab
{ title: 'Documentation', url: '/', urls: new Set(source.getPages().map((page) => page.url)) },
...navTabs.map((tab) => ({
title: tab.label,
url: tab.href,
props: { target: '_blank', rel: 'noreferrer noopener' },
})),
];
/** Mintlify-style grid, defined as `--pg-grid` in app/global.css (responsive) */
const containerStyle = { gridTemplate: 'var(--pg-grid)' } as CSSProperties;
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<>
<Banner id={banner.id} className="pg-banner">
<p>
<strong>AI Gateway</strong> is now available
<span className="max-md:hidden">
: identity-aware access to any AI provider, eliminate API keys, and tunnel to
self-hosted models
</span>
.{' '}
<Link href={banner.link.href} className="underline underline-offset-2 font-medium">
{banner.link.label}
</Link>
</p>
</Banner>
{/* chat state wraps the whole layout so the header "Ask AI" button can open it */}
<AISearch>
<DocsLayout
{...baseOptions()}
slots={{ searchTrigger: headerSearchSlot }}
tree={getNavigationTree()}
nav={{ ...baseOptions().nav, mode: 'top' }}
tabMode="navbar"
tabs={tabs}
sidebar={{
defaultOpenLevel: 0,
collapsible: false,
banner: SidebarAnchors,
}}
containerProps={{ style: containerStyle }}
>
<AISearchPanel />
{children}
</DocsLayout>
</AISearch>
</>
);
}
+80
View File
@@ -0,0 +1,80 @@
import {
convertToModelMessages,
createUIMessageStreamResponse,
stepCountIs,
streamText,
toUIMessageStream,
type SystemModelMessage,
} from 'ai';
import type { ChatUIMessage } from '@/components/ai/search';
import { AIConfigError, getModel } from '@/lib/ai/model';
import { getSystemPrompt } from '@/lib/ai/prompt';
import { checkRateLimit, clientKey } from '@/lib/ai/rate-limit';
import { tools } from '@/lib/ai/tools';
export const maxDuration = 120;
const MAX_MESSAGES = 30;
const MAX_MESSAGE_CHARS = 8_000;
function json(status: number, message: string, headers?: HeadersInit) {
return Response.json({ error: message }, { status, headers });
}
export async function POST(req: Request) {
const limit = checkRateLimit(clientKey(req));
if (!limit.ok) {
return json(429, `Too many questions, try again in ${limit.retryAfter}s.`, {
'Retry-After': String(limit.retryAfter),
});
}
let config;
try {
config = getModel();
} catch (e) {
if (e instanceof AIConfigError) return json(503, e.message);
throw e;
}
const body = (await req.json().catch(() => null)) as { messages?: ChatUIMessage[] } | null;
const messages = (body?.messages ?? []).slice(-MAX_MESSAGES);
const tooLong = messages.some((m) =>
m.parts.some((p) => p.type === 'text' && p.text.length > MAX_MESSAGE_CHARS),
);
if (messages.length === 0 || tooLong) return json(400, 'Invalid or too long message.');
const instructions: SystemModelMessage = {
role: 'system',
content: getSystemPrompt(),
// the system prompt is large and identical across requests
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } },
};
const result = streamText({
model: config.model,
stopWhen: stepCountIs(8),
tools,
toolChoice: 'auto',
instructions,
messages: await convertToModelMessages<ChatUIMessage>(messages, {
convertDataPart(part) {
if (part.type === 'data-client')
return {
type: 'text',
text: `[Client Context: ${JSON.stringify(part.data)}]`,
};
},
}),
onError({ error }) {
console.error('[api/chat]', error);
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
onError: () => 'The assistant hit an error. Please try again.',
}),
});
}
+3
View File
@@ -0,0 +1,3 @@
import { searchServer } from '@/lib/search';
export const { GET } = searchServer;
+1023
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import type { Metadata, Viewport } from 'next';
import { Inter } from 'next/font/google';
import { Analytics } from '@/components/analytics';
import { appName, enableDarkMode, siteDescription, siteUrl } from '@/lib/shared';
import './global.css';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
});
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: { template: `%s - ${appName}`, default: appName },
description: siteDescription,
icons: { icon: '/favicon.png' },
openGraph: { siteName: appName, images: '/images/home-social-graph.png' },
twitter: { card: 'summary_large_image' },
};
export const viewport: Viewport = {
themeColor: '#FAF9F2',
};
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider
theme={
enableDarkMode
? { defaultTheme: 'light' }
: { forcedTheme: 'light', defaultTheme: 'light' }
}
>
{children}
</RootProvider>
<Analytics />
</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { getPageText, source } from '@/lib/source';
import { getOrderedPagePaths } from '@/lib/navigation';
export const revalidate = false;
export async function GET() {
const pages = source.getPages();
const byPath = new Map(pages.map((page) => [page.slugs.join('/') || 'index', page]));
// sidebar order first, then everything that isn't listed in the sidebar
const ordered = getOrderedPagePaths()
.map((path) => byPath.get(path))
.filter((page) => page !== undefined);
const rest = pages.filter((page) => !ordered.includes(page));
const texts = await Promise.all([...ordered, ...rest].map(getPageText));
return new Response(texts.join('\n\n---\n\n'), {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
+24
View File
@@ -0,0 +1,24 @@
import { docsLlms, source } from '@/lib/source';
import { getPageMarkdownUrl } from '@/lib/shared';
import { notFound } from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/[[...slug]]'>) {
const { slug } = await params;
// last segment is always `content.md`
const page = source.getPage(slug?.slice(0, -1));
if (!page) notFound();
return new Response(await docsLlms.page(page), {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
},
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
slug: getPageMarkdownUrl(page).segments,
}));
}
+62
View File
@@ -0,0 +1,62 @@
import { source } from '@/lib/source';
import { getNavigationTree } from '@/lib/navigation';
import { appName, siteDescription, siteUrl } from '@/lib/shared';
import type * as PageTree from 'fumadocs-core/page-tree';
export const revalidate = false;
/** https://llmstxt.org — follows the sidebar structure, plus pages not listed in it */
export async function GET() {
const tree = getNavigationTree();
const lines: string[] = [
`# ${appName}`,
'',
`> ${siteDescription}`,
'',
`The full documentation as a single file is available at ${siteUrl}/llms-full.txt. Append \`.md\` to any page URL to get its Markdown source.`,
'',
];
const listed = new Set<string>();
function descriptionOf(url: string) {
const page = source.getPages().find((p) => p.url === url);
return page?.data.description;
}
function item(node: PageTree.Item, depth: number) {
listed.add(node.url);
const description = descriptionOf(node.url);
lines.push(
`${' '.repeat(depth)}- [${String(node.name)}](${siteUrl}${node.url}.md)${description ? `: ${description}` : ''}`,
);
}
function walk(nodes: PageTree.Node[], depth: number) {
for (const node of nodes) {
if (node.type === 'separator') {
lines.push('', `## ${String(node.name)}`, '');
} else if (node.type === 'page') {
item(node, depth);
} else {
lines.push(`${' '.repeat(depth)}- ${String(node.name)}`);
if (node.index) item(node.index, depth + 1);
walk(node.children, depth + 1);
}
}
}
walk(tree.children, 0);
const optional = source.getPages().filter((page) => !listed.has(page.url));
if (optional.length > 0) {
lines.push('', '## Optional', '');
for (const page of optional) {
lines.push(
`- [${page.data.title}](${siteUrl}${page.url}.md)${page.data.description ? `: ${page.data.description}` : ''}`,
);
}
}
return new Response(lines.join('\n').replace(/\/\.md\)/g, '/index.md)') + '\n', {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
+29
View File
@@ -0,0 +1,29 @@
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { registerSearchTool, registerSourceTools } from 'fumadocs-core/mcp';
import { docsLlms, source } from '@/lib/source';
import { searchServer } from '@/lib/search';
import { appName } from '@/lib/shared';
const handler = createMcpHandler(() => {
const mcp = new McpServer({
name: appName,
version: '1.0.0',
});
registerSourceTools(mcp, source, docsLlms);
registerSearchTool(mcp, searchServer);
return mcp;
});
export function GET(request: Request) {
return handler.fetch(request);
}
export function POST(request: Request) {
return handler.fetch(request);
}
export function DELETE(request: Request) {
return handler.fetch(request);
}
+26
View File
@@ -0,0 +1,26 @@
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
import { generateOGImage } from 'fumadocs-ui/og';
import { appName, getPageImageUrl } from '@/lib/shared';
export const revalidate = false;
export async function GET(_req: Request, { params }: RouteContext<'/og/[...slug]'>) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return generateOGImage({
title: page.data.title,
description: page.data.description,
site: appName,
primaryColor: 'rgba(243, 97, 23, 0.35)',
primaryTextColor: 'rgb(243, 97, 23)',
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
slug: getPageImageUrl(page).segments,
}));
}
-67
View File
@@ -1,67 +0,0 @@
---
title: "Software Engineer - Backend"
---
- Location: `New York City`
- Salary: `$125k - $185k + 0.5% - 1.5% equity`
- Years of experience: `3+`
- Skills: `Go, TypeScript, SQL (PostgreSQL, SQLite), Express, Drizzle ORM`
## About Pangolin
Pangolin delivers identity-aware remote access to internal apps and services. Our platform replaces legacy VPNs and simplifies secure access to infrastructure, applications, and developer environments.
We build in the open and are self‑hosted by default so teams retain control over data and infrastructure. The system is policy‑driven, integrates with standard IdPs, exposes clear observability and health, and provides an API for automation.
If you're interested in open-source auth and networking infrastructure, we'd love to chat.
## About The Role
As a Backend Software Engineer at Pangolin, you'll build and maintain the server that everything else in the system talks to: the API, the database schema, auth flows, and the integrations that connect Pangolin to identity providers and infrastructure. As an early hire, you'll play a foundational role in shaping how Pangolin is built as both a product and a company.
## What You'll Do
- Design and build APIs, database schemas, and background jobs that the rest of the platform depends on
- Implement and maintain integrations with IdPs (OAuth2, OIDC, SAML) and other external systems
- Write migrations and manage schema changes across a production database used by self-hosted deployments
- Troubleshoot issues that show up in the field, from race conditions to bad data to performance regressions
- Actively engage with the open-source community, providing support, and driving engagement on platforms like GitHub and Discord
- Ship quickly and get instant feedback from our large open-source user-base
## What We're Looking For
### High Level
- Must be a current or recent graduate with more than 3 years of work experience in computer science
- Must be authorized to work in the US
- Must be comfortable with and excited by early stage startup culture and figuring things out as we go
- Must have your own ideas and be willing to communicate them
### Other Technical Qualifications
- Strong experience with Go or TypeScript backend services, ideally both
- Comfortable designing SQL schemas and writing migrations (PostgreSQL and/or SQLite)
- Familiarity with web identity and authentication standards: OAuth2, OIDC, SSO
- Experience with cloud infrastructure and operational technologies (Docker, Kubernetes, Linux, AWS)
## What You Can Expect
- Competitive salary
- In-person (New York City)
- Quiet work environment
- Small, trusting team of founders and engineers
- Relocation assistance
- Unlimited PTO
## Process
1. We will review your provided materials
2. Complete a quick “get to know you interview” with founders
3. 1-2 technical interviews
4. Fly out to meet the team for a day (paid if not hired)
5. Onboard!
## How to Apply
1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net)
2. Include your GitHub profile and highlight any past projects
3. Include a brief thoughtful message about why you're interested in Pangolin and this role
-67
View File
@@ -1,67 +0,0 @@
---
title: "Software Engineer - Frontend"
---
- Location: `New York City`
- Salary: `$125k - $185k + 0.5% - 1.5% equity`
- Years of experience: `3+`
- Skills: `TypeScript, NextJS, Tailwind, ShadCN`
## About Pangolin
Pangolin delivers identity-aware remote access to internal apps and services. Our platform replaces legacy VPNs and simplifies secure access to infrastructure, applications, and developer environments.
We build in the open and are self‑hosted by default so teams retain control over data and infrastructure. The system is policy‑driven, integrates with standard IdPs, exposes clear observability and health, and provides an API for automation.
If you're interested in open-source auth and networking infrastructure, we'd love to chat.
## About The Role
As a Frontend Software Engineer at Pangolin, you'll own the UI that administrators use to configure sites, resources, and policies. That means the dashboard itself, but also the forms, tables, and flows that make a system with a lot of moving parts feel simple to operate. As an early hire, you'll play a foundational role in shaping how Pangolin is built as both a product and a company.
## What You'll Do
- Design, build, and maintain the Pangolin dashboard (NextJS, Tailwind, ShadCN)
- Turn complex configuration (sites, resources, policies, users) into interfaces that are fast and hard to misuse
- Work closely with backend engineers to shape the APIs your UI consumes
- Troubleshoot issues reported by our self-hosted user-base, from rendering bugs to state management edge cases
- Actively engage with the open-source community, providing support, and driving engagement on platforms like GitHub and Discord
- Ship quickly and get instant feedback from our large open-source user-base
## What We're Looking For
### High Level
- Must be a current or recent graduate with more than 3 years of work experience in computer science
- Must be authorized to work in the US
- Must be comfortable with and excited by early stage startup culture and figuring things out as we go
- Must have your own ideas and be willing to communicate them
### Other Technical Qualifications
- Extensive TypeScript and React experience, ideally with NextJS
- Strong eye for interaction design and can turn a rough idea into a usable form or flow
- Familiarity with web identity and authentication standards: OAuth2, OIDC, SSO
- Comfortable working against real backend APIs and SQL-backed data models
## What You Can Expect
- Competitive salary
- In-person (New York City)
- Quiet work environment
- Small, trusting team of founders and engineers
- Relocation assistance
- Unlimited PTO
## Process
1. We will review your provided materials
2. Complete a quick “get to know you interview” with founders
3. 1-2 technical interviews
4. Fly out to meet the team for a day (paid if not hired)
5. Onboard!
## How to Apply
1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net)
2. Include your GitHub profile and highlight any past projects
3. Include a brief thoughtful message about why you're interested in Pangolin and this role
-68
View File
@@ -1,68 +0,0 @@
---
title: "Software Lead"
---
- Location: `New York City`
- Salary: `$160k - $220k + 1% - 2% equity`
- Years of experience: `6+`
- Skills: `TypeScript, Go, SQL (PostgreSQL, SQLite), NextJS, System Design, Team Leadership`
## About Pangolin
Pangolin delivers identity-aware remote access to internal apps and services. Our platform replaces legacy VPNs and simplifies secure access to infrastructure, applications, and developer environments.
We build in the open and are self‑hosted by default so teams retain control over data and infrastructure. The system is policy‑driven, integrates with standard IdPs, exposes clear observability and health, and provides an API for automation.
If you're interested in open-source auth and networking infrastructure, we'd love to chat.
## About The Role
As Software Lead at Pangolin, you'll set technical direction across the stack, from the dashboard down to the tunneling layer, and help the rest of the engineering team make good decisions faster. You'll still write code day to day, but you'll also own architecture calls, review design tradeoffs, and mentor engineers as the team grows. As an early hire, you'll play a foundational role in shaping how Pangolin is built as both a product and a company.
## What You'll Do
- Set direction on architecture: schema design, API boundaries, and how the frontend, backend, and tunneling components fit together
- Review code and designs across the team, and push back when something won't hold up in production
- Pair with founders on roadmap and technical strategy, including build-vs-buy and where to invest engineering time
- Mentor other engineers and help onboard new hires as the team grows
- Stay hands-on: ship features, fix bugs, and carry an on-call rotation like everyone else
- Actively engage with the open-source community, providing support, and driving engagement on platforms like GitHub and Discord
## What We're Looking For
### High Level
- Must have 6+ years of professional software engineering experience, including time spent leading projects or mentoring other engineers
- Must be authorized to work in the US
- Must be comfortable with and excited by early stage startup culture and figuring things out as we go
- Must have your own ideas and be willing to communicate and defend them
### Other Technical Qualifications
- Strong full-stack experience across TypeScript and Go, with the judgment to know when to use each
- Track record of making architecture decisions that held up as a system and team grew
- Familiarity with web identity and authentication standards: OAuth2, OIDC, SSO
- Experience with cloud infrastructure and operational technologies (Docker, Kubernetes, Linux, AWS)
- Working knowledge of networking concepts: tunneling, WireGuard, proxies, SSL/TLS
## What You Can Expect
- Competitive salary
- In-person (New York City)
- Quiet work environment
- Small, trusting team of founders and engineers
- Relocation assistance
- Unlimited PTO
## Process
1. We will review your provided materials
2. Complete a quick “get to know you interview” with founders
3. 1-2 technical interviews
4. Fly out to meet the team for a day (paid if not hired)
5. Onboard!
## How to Apply
1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net)
2. Include your GitHub profile and highlight any past projects
3. Include a brief thoughtful message about why you're interested in Pangolin and this role
-67
View File
@@ -1,67 +0,0 @@
---
title: "Software Engineer - Networking"
---
- Location: `New York City`
- Salary: `$125k - $185k + 0.5% - 1.5% equity`
- Years of experience: `3+`
- Skills: `Go, WireGuard, NAT traversal, TCP/IP, TLS`
## About Pangolin
Pangolin delivers identity-aware remote access to internal apps and services. Our platform replaces legacy VPNs and simplifies secure access to infrastructure, applications, and developer environments.
We build in the open and are self‑hosted by default so teams retain control over data and infrastructure. The system is policy‑driven, integrates with standard IdPs, exposes clear observability and health, and provides an API for automation.
If you're interested in open-source auth and networking infrastructure, we'd love to chat.
## About The Role
As a Networking Software Engineer at Pangolin, you'll work on the tunneling layer that the rest of the product depends on: Newt, Gerbil, and Olm, and the hole-punching and WireGuard plumbing that connects sites and clients without a traditional VPN. As an early hire, you'll play a foundational role in shaping how Pangolin is built as both a product and a company.
## What You'll Do
- Build and harden the hub-and-spoke, hole-punched, zero-trust tunneling system that connects sites, clients, and the control plane
- Work on WireGuard configuration, NAT traversal, and connection recovery across unreliable networks
- Debug connectivity issues that only show up in the field: odd router configs, symmetric NAT, flaky links
- Improve observability into tunnel health so problems surface before users report them
- Actively engage with the open-source community, providing support, and driving engagement on platforms like GitHub and Discord
- Ship quickly and get instant feedback from our large open-source user-base
## What We're Looking For
### High Level
- Must be a current or recent graduate with more than 3 years of work experience in computer science
- Must be authorized to work in the US
- Must be comfortable with and excited by early stage startup culture and figuring things out as we go
- Must have your own ideas and be willing to communicate them
### Other Technical Qualifications
- Extensive Go experience and can write a little TypeScript
- Deep knowledge of networking concepts: tunneling, WireGuard, NAT traversal, proxies, SSL/TLS
- Experience with cloud infrastructure and operational technologies (Docker, Kubernetes, Linux, AWS)
- Comfortable debugging at the packet level when logs alone don't explain what's happening
## What You Can Expect
- Competitive salary
- In-person (New York City)
- Quiet work environment
- Small, trusting team of founders and engineers
- Relocation assistance
- Unlimited PTO
## Process
1. We will review your provided materials
2. Complete a quick “get to know you interview” with founders
3. 1-2 technical interviews
4. Fly out to meet the team for a day (paid if not hired)
5. Onboard!
## How to Apply
1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net)
2. Include your GitHub profile and highlight any past projects
3. Include a brief thoughtful message about why you're interested in Pangolin and this role
-13
View File
@@ -1,13 +0,0 @@
---
title: "Changelog"
description: "Updates and announcements"
---
<Update label="March 2025" description="v0.0.10">
Added a new Wintergreen flavor.
Released a new version of the Spearmint flavor, now with 10% more mint.
</Update>
<Update label="February 2025" description="v0.0.09">
Released a new version of the Spearmint flavor.
</Update>
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { useState, type FormEvent } from 'react';
import { ArrowUp } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useAISearchContext, useAskAI } from './search';
/**
* Mintlify-style "Ask a question..." bar that sticks to the bottom of the page content.
* Submitting opens the chat panel with the question; it hides while the panel is open.
*/
export function AskBar() {
const { open } = useAISearchContext();
const ask = useAskAI();
const [value, setValue] = useState('');
function onSubmit(e: FormEvent) {
e.preventDefault();
const text = value.trim();
if (!text) return;
ask(text);
setValue('');
}
return (
<div className={cn('pg-askbar-wrap', open && 'pg-askbar-hidden')} aria-hidden={open}>
<form onSubmit={onSubmit} className="pg-chat-input pg-askbar">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Ask a question..."
aria-label="Ask AI about the docs"
tabIndex={open ? -1 : undefined}
/>
<kbd className="pg-askbar-kbd">⌘I</kbd>
<button
type="submit"
className="pg-chat-send"
aria-label="Send"
disabled={value.trim().length === 0}
tabIndex={open ? -1 : undefined}
>
<ArrowUp className="size-4" />
</button>
</form>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { Sparkles } from 'lucide-react';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { cn } from '@/lib/cn';
import { useAskAI } from './search';
export function AskAIAboutPage({ title }: { title: string }) {
const ask = useAskAI();
return (
<button
type="button"
className={cn(
buttonVariants({ color: 'secondary', size: 'sm' }),
'gap-2 [&_svg]:size-3.5 [&_svg]:text-fd-muted-foreground',
)}
onClick={() => ask(`Summarize the "${title}" page I'm on and what I should do next.`)}
>
<Sparkles />
Ask AI
</button>
);
}
+580
View File
@@ -0,0 +1,580 @@
'use client';
import {
type ComponentProps,
createContext,
type ReactNode,
type SyntheticEvent,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import { flushSync } from 'react-dom';
import { ArrowUp, FileText, Loader2, RefreshCw, SearchIcon, Sparkles, Square, X } from 'lucide-react';
import { cn } from '../../lib/cn';
import { buttonVariants } from '../ui/button';
import { useChat, type UseChatHelpers } from '@ai-sdk/react';
import { DefaultChatTransport, type UIMessage } from 'ai';
import { Markdown } from '../markdown';
export type ChatUIMessage = UIMessage<
never,
{
client: {
location: string;
};
}
>;
const Context = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
chat: UseChatHelpers<ChatUIMessage>;
} | null>(null);
export function AISearchPanelHeader({ className, ...props }: ComponentProps<'div'>) {
const { setOpen } = useAISearchContext();
return (
<div
className={cn(
'sticky top-0 flex items-start gap-2 border rounded-xl bg-fd-secondary text-fd-secondary-foreground shadow-sm',
className,
)}
{...props}
>
<div className="px-3 py-2 flex-1">
<p className="text-sm font-medium mb-1 flex items-center gap-1.5">
<Sparkles className="size-3.5 text-fd-primary" />
Ask Pangolin AI
</p>
<p className="text-xs text-fd-muted-foreground">
Answers are generated from the docs and can be wrong. Check the linked pages.
</p>
</div>
<button
aria-label="Close"
tabIndex={-1}
className={cn(
buttonVariants({
size: 'icon-sm',
variant: 'ghost',
className: 'text-fd-muted-foreground rounded-full',
}),
)}
onClick={() => setOpen(false)}
>
<X />
</button>
</div>
);
}
export function AISearchInputActions() {
const { messages, status, setMessages, regenerate } = useChatContext();
const isLoading = status === 'streaming';
if (messages.length === 0) return null;
return (
<>
{!isLoading && messages.at(-1)?.role === 'assistant' && (
<button
type="button"
className={cn(
buttonVariants({
variant: 'secondary',
size: 'sm',
className: 'rounded-full gap-1.5',
}),
)}
onClick={() => regenerate()}
>
<RefreshCw className="size-4" />
Retry
</button>
)}
<button
type="button"
className={cn(
buttonVariants({
variant: 'secondary',
size: 'sm',
className: 'rounded-full',
}),
)}
onClick={() => setMessages([])}
>
Clear Chat
</button>
</>
);
}
const suggestions = [
'How do I expose a web app running on my home server?',
'What is the difference between a site and a client?',
'How do I self-host Pangolin with Docker Compose?',
'How do I connect Claude Code to the AI Gateway?',
];
function sendText(send: UseChatHelpers<ChatUIMessage>['sendMessage'], text: string) {
void send({
role: 'user',
parts: [
{ type: 'data-client', data: { location: location.href } },
{ type: 'text', text },
],
});
}
/** open the panel and immediately ask `text` */
export function useAskAI() {
const { setOpen, chat } = useAISearchContext();
return (text: string) => {
setOpen(true);
if (chat.status === 'streaming' || chat.status === 'submitted') return;
sendText(chat.sendMessage, text);
};
}
const StorageKeyInput = '__ai_search_input';
export function AISearchInput(props: ComponentProps<'form'>) {
const { status, sendMessage, stop } = useChatContext();
const [input, setInput] = useState(() => {
try {
return localStorage.getItem(StorageKeyInput) ?? '';
} catch {
return '';
}
});
const isLoading = status === 'streaming' || status === 'submitted';
const onStart = (e?: SyntheticEvent) => {
e?.preventDefault();
const message = input.trim();
if (message.length === 0) return;
sendText(sendMessage, message);
setInput('');
try {
localStorage.removeItem(StorageKeyInput);
} catch {
// storage unavailable
}
};
useEffect(() => {
if (isLoading) document.getElementById('nd-ai-input')?.focus();
}, [isLoading]);
return (
<form {...props} className={cn('flex items-end gap-2 p-2', props.className)} onSubmit={onStart}>
<Input
value={input}
placeholder={isLoading ? 'Pangolin AI is answering…' : 'Ask a question...'}
autoFocus
className="px-2 py-1.5 text-sm"
disabled={status === 'streaming' || status === 'submitted'}
onChange={(e) => {
setInput(e.target.value);
try {
localStorage.setItem(StorageKeyInput, e.target.value);
} catch {
// storage unavailable
}
}}
onKeyDown={(event) => {
// keyCode 229: Safari fires `compositionend` before this keydown, `isComposing` is already false
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (!event.shiftKey && event.key === 'Enter') {
onStart(event);
}
}}
/>
{isLoading ? (
<button key="bn" type="button" className="pg-chat-send" aria-label="Stop answering" onClick={stop}>
<Square className="size-3 fill-current" />
</button>
) : (
<button
key="bn"
type="submit"
className="pg-chat-send"
aria-label="Send"
disabled={input.trim().length === 0}
>
<ArrowUp className="size-4" />
</button>
)}
</form>
);
}
function List(props: Omit<ComponentProps<'div'>, 'dir'>) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
function callback() {
const container = containerRef.current;
if (!container) return;
container.scrollTo({
top: container.scrollHeight,
behavior: 'instant',
});
}
const observer = new ResizeObserver(callback);
callback();
const element = containerRef.current?.firstElementChild;
if (element) {
observer.observe(element);
}
return () => {
observer.disconnect();
};
}, []);
return (
<div
ref={containerRef}
{...props}
className={cn('fd-scroll-container overflow-y-auto min-w-0 flex flex-col', props.className)}
>
{props.children}
</div>
);
}
function Input(props: ComponentProps<'textarea'>) {
const ref = useRef<HTMLDivElement>(null);
const shared = cn('pg-chat-field col-start-1 row-start-1', props.className);
return (
<div className="grid flex-1">
<textarea
id="nd-ai-input"
{...props}
className={cn(
'resize-none bg-transparent placeholder:text-fd-muted-foreground focus-visible:outline-none',
shared,
)}
/>
<div ref={ref} className={cn(shared, 'break-all invisible')}>
{`${props.value?.toString() ?? ''}\n`}
</div>
</div>
);
}
const roleName: Record<string, string> = {
user: 'You',
assistant: 'Pangolin AI',
};
interface ToolPart {
type: string;
toolCallId: string;
state: string;
input?: { query?: string; path?: string };
output?: unknown;
errorText?: string;
}
function ToolActivity({ part }: { part: ToolPart }) {
const name = part.type.slice('tool-'.length);
const failed = part.state === 'output-error' || part.state === 'output-denied';
const done = part.state === 'output-available';
let label: ReactNode;
if (name === 'search_docs') {
const count = Array.isArray(part.output) ? part.output.length : 0;
label = done ? (
<>
Searched <q>{part.input?.query}</q>, {count} {count === 1 ? 'page' : 'pages'}
</>
) : (
<>Searching <q>{part.input?.query ?? '…'}</q></>
);
} else if (name === 'read_page') {
label = <>{done ? 'Read' : 'Reading'} <code>{part.input?.path ?? '…'}</code></>;
} else {
label = name;
}
const Icon = name === 'read_page' ? FileText : SearchIcon;
return (
<div className="flex flex-row gap-2 items-center rounded-lg border bg-fd-secondary text-fd-muted-foreground text-xs px-2 py-1.5 min-w-0">
{done || failed ? <Icon className="size-3.5 shrink-0" /> : <Loader2 className="size-3.5 shrink-0 animate-spin" />}
{failed ? (
<p className="text-fd-error truncate">{part.errorText ?? 'Tool call failed'}</p>
) : (
<p className="truncate">{label}</p>
)}
</div>
);
}
function Message({ message, ...props }: { message: ChatUIMessage } & ComponentProps<'div'>) {
let markdown = '';
const toolCalls: ToolPart[] = [];
for (const part of message.parts ?? []) {
if (part.type === 'text') {
markdown += part.text;
continue;
}
if (part.type.startsWith('tool-')) {
const p = part as unknown as ToolPart;
if (p.toolCallId) toolCalls.push(p);
}
}
return (
<div onClick={(e) => e.stopPropagation()} {...props}>
<p
className={cn(
'mb-1 text-sm font-medium text-fd-muted-foreground',
message.role === 'assistant' && 'text-fd-primary',
)}
>
{roleName[message.role] ?? 'unknown'}
</p>
{toolCalls.length > 0 && (
<div className="flex flex-col gap-1 mb-2">
{toolCalls.map((call) => (
<ToolActivity key={call.toolCallId} part={call} />
))}
</div>
)}
<div className="prose text-sm">
<Markdown text={markdown} />
</div>
</div>
);
}
export function AISearch({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const chat = useChat<ChatUIMessage>({
id: 'search',
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
return (
<Context value={useMemo(() => ({ chat, open, setOpen }), [chat, open])}>{children}</Context>
);
}
export function AISearchTrigger({
position = 'default',
className,
...props
}: ComponentProps<'button'> & { position?: 'default' | 'float' }) {
const { open, setOpen } = useAISearchContext();
return (
<button
data-state={open ? 'open' : 'closed'}
className={cn(
position === 'float' && [
'fixed bottom-4 gap-2 inset-e-[calc(--spacing(4)+var(--removed-body-scroll-bar-size,0px))] shadow-lg z-20 transition-[translate,opacity]',
open && 'translate-y-10 opacity-0',
],
className,
)}
onClick={() => setOpen(!open)}
{...props}
>
{props.children}
</button>
);
}
export function AISearchPanel() {
const { open, setOpen } = useAISearchContext();
const [actualOpen, setActualOpen] = useState(open);
useHotKey();
if (open && !actualOpen) setActualOpen(open);
// The docs grid reserves a column for the panel while this attribute is set (see
// `--pg-panel` in app/global.css). It follows `open` directly, so the content moves
// back as soon as the panel starts closing, whatever happens to the close animation.
useEffect(() => {
const layout = document.getElementById('nd-notebook-layout');
layout?.toggleAttribute('data-ai-open', open);
return () => layout?.removeAttribute('data-ai-open');
}, [open]);
// Unmount after the close animation. `animationend` never fires when animations are
// skipped (background tab, reduced motion), so also fall back to a timer.
useEffect(() => {
if (open || !actualOpen) return;
const timer = window.setTimeout(() => setActualOpen(false), 300);
return () => window.clearTimeout(timer);
}, [open, actualOpen]);
return (
<>
<style>
{`
@keyframes ask-ai-open {
from {
translate: 100% 0;
}
to {
translate: 0 0;
}
}
@keyframes ask-ai-close {
from {
width: var(--ai-chat-width);
}
to {
width: 0px;
}
}`}
</style>
{actualOpen && (
<div
className={cn(
'fixed inset-0 z-30 backdrop-blur-xs bg-fd-overlay lg:hidden',
open ? 'animate-fd-fade-in' : 'animate-fd-fade-out',
)}
onClick={() => setOpen(false)}
onAnimationEnd={() => {
if (!open) flushSync(() => setActualOpen(false));
}}
/>
)}
{actualOpen && (
<div
className={cn(
'pg-ai-panel overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-(--fd-docs-row-2) lg:h-[calc(100dvh-var(--fd-docs-row-2))] lg:border-s lg:ms-auto lg:in-[#nd-notebook-layout]:[grid-area:2/5/4/6]',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
)}
onAnimationEnd={() => {
if (!open) flushSync(() => setActualOpen(false));
}}
>
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
<AISearchPanelHeader />
<AISearchPanelList className="flex-1" />
<div className="pg-chat-input">
<AISearchInput />
<div className="flex items-center gap-1.5 px-2 pb-2 empty:hidden">
<AISearchInputActions />
</div>
</div>
</div>
</div>
)}
</>
);
}
export function AISearchPanelList({ className, style, ...props }: ComponentProps<'div'>) {
const chat = useChatContext();
const messages = chat.messages.filter((msg) => msg.role !== 'system');
return (
<List
className={cn('py-4 overscroll-contain', className)}
style={{
maskImage:
'linear-gradient(to bottom, transparent, white 1rem, white calc(100% - 1rem), transparent 100%)',
...style,
}}
{...props}
>
{messages.length === 0 ? (
<div
className="size-full flex flex-col justify-end gap-2 px-1"
onClick={(e) => e.stopPropagation()}
>
<p className="text-sm text-fd-muted-foreground mb-1">
Ask anything about Pangolin. The assistant searches and reads these docs to answer.
</p>
{suggestions.map((q) => (
<button
key={q}
type="button"
className="text-start text-sm rounded-xl border bg-fd-card px-3 py-2 text-fd-foreground transition-colors hover:bg-fd-accent"
onClick={() => sendText(chat.sendMessage, q)}
>
{q}
</button>
))}
</div>
) : (
<div className="flex flex-col px-3 gap-4">
{messages.map((item) => (
<Message key={item.id} message={item} />
))}
{chat.error && (
<div className="p-2 bg-fd-secondary text-fd-secondary-foreground border rounded-lg">
<p className="text-xs text-fd-muted-foreground mb-1">Request failed</p>
<p className="text-sm">{errorText(chat.error)}</p>
</div>
)}
</div>
)}
</List>
);
}
/** `/api/chat` returns `{ error }` JSON for config / rate-limit errors */
function errorText(error: Error) {
try {
const parsed = JSON.parse(error.message) as { error?: string };
if (parsed.error) return parsed.error;
} catch {
// not JSON
}
return error.message;
}
export function useHotKey() {
const { open, setOpen } = useAISearchContext();
const onKeyPress = useEffectEvent((e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
setOpen(false);
e.preventDefault();
}
if ((e.key === '/' || e.key === 'i') && (e.metaKey || e.ctrlKey) && !open) {
setOpen(true);
e.preventDefault();
}
});
useEffect(() => {
window.addEventListener('keydown', onKeyPress);
return () => window.removeEventListener('keydown', onKeyPress);
}, []);
}
export function useAISearchContext() {
return use(Context)!;
}
function useChatContext() {
return use(Context)!.chat;
}
+38
View File
@@ -0,0 +1,38 @@
import Script from 'next/script';
/**
* Same analytics as the Mintlify site (PostHog via the pangolin.net relay, Rybbit, Reo).
* Only loaded in production builds; set NEXT_PUBLIC_DISABLE_ANALYTICS=1 to turn off.
*/
const POSTHOG_KEY = 'phc_RIHQ7o2Y2hf8qms2nP62vpoJHEvsrw6TieflQGQO7yI';
const POSTHOG_HOST = 'https://pangolin.net/relay-O7yI';
const RYBBIT_SITE_ID = 'da4fb64dc2d5';
const REO_CLIENT_ID = '4209f8e1b88e13b';
export function Analytics() {
if (process.env.NODE_ENV !== 'production' || process.env.NEXT_PUBLIC_DISABLE_ANALYTICS) {
return null;
}
return (
<>
<Script id="posthog" strategy="afterInteractive">
{`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init(${JSON.stringify(POSTHOG_KEY)},{api_host:${JSON.stringify(POSTHOG_HOST)},ui_host:'https://us.posthog.com',person_profiles:'identified_only'});`}
</Script>
<Script
src="https://rybbit.fossorial.io/api/script.js"
data-site-id={RYBBIT_SITE_ID}
strategy="afterInteractive"
/>
<Script
src={`https://static.reo.dev/${REO_CLIENT_ID}/reo.js`}
strategy="afterInteractive"
id="reo"
/>
<Script id="reo-init" strategy="lazyOnload">
{`(function w(){if(window.Reo){Reo.init({clientID:${JSON.stringify(REO_CLIENT_ID)}})}else setTimeout(w,500)})();`}
</Script>
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
'use client';
import { Sparkles } from 'lucide-react';
import {
FullSearchTrigger,
SearchTrigger,
type FullSearchTriggerProps,
type SearchTriggerProps,
} from 'fumadocs-ui/layouts/shared/slots/search-trigger';
import { cn } from '@/lib/cn';
import { useAISearchContext } from './ai/search';
/**
* Header search slot: the normal search bar with an "Ask AI" button to its right, like
* Mintlify. The layout passes the search bar's sizing classes; they go on the wrapper.
*/
function HeaderSearchFull({ className, ...props }: FullSearchTriggerProps) {
const { open, setOpen } = useAISearchContext();
return (
<div className={cn(className, 'flex items-center gap-2 ps-0 max-w-md')}>
<FullSearchTrigger {...props} className="ps-2.5 rounded-xl flex-1 min-w-0 h-9" />
<button
type="button"
className="pg-ask-ai-btn"
data-state={open ? 'open' : 'closed'}
aria-pressed={open}
onClick={() => setOpen(!open)}
>
<Sparkles />
Ask AI
</button>
</div>
);
}
function HeaderSearchSm(props: SearchTriggerProps) {
return <SearchTrigger {...props} />;
}
export const headerSearchSlot = { full: HeaderSearchFull, sm: HeaderSearchSm };
+206
View File
@@ -0,0 +1,206 @@
import type { SVGProps } from 'react';
import {
BadgeCheck,
Bell,
Bolt,
BookOpen,
Bot,
Box,
Boxes,
Brain,
Brush,
Building2,
ChartColumn,
ChartLine,
Check,
CircleHelp,
Cloud,
CreditCard,
Database,
Download,
FileCode,
GitBranch,
Globe,
Hash,
IdCard,
Key,
Layers,
LayoutGrid,
Link,
ListChecks,
Lock,
Mail,
Monitor,
Network,
PaintBucket,
Plug,
Route,
Server,
Shield,
SlidersHorizontal,
Sparkles,
TableProperties,
Terminal,
User,
Users,
Waypoints,
Workflow,
X,
type LucideIcon,
} from 'lucide-react';
type IconComponent = LucideIcon | ((props: SVGProps<SVGSVGElement>) => React.ReactElement);
// Brand icons are not part of lucide, so they are inlined here.
export function GitHubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-6 0-1.2.5-2.3 1.3-3.1-.2-.4-.6-1.6 0-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 0 1 6 0c2.3-1.5 3.3-1.2 3.3-1.2.6 1.6.2 2.8 0 3.2.9.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3" />
</svg>
);
}
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M20.3 4.4A19.8 19.8 0 0 0 15.4 3l-.6 1.3a18.3 18.3 0 0 0-5.5 0L8.6 3a19.7 19.7 0 0 0-4.9 1.5A20.3 20.3 0 0 0 .1 18.1a19.9 19.9 0 0 0 6 3l1.3-2a12.9 12.9 0 0 1-2-1l.5-.4a14.2 14.2 0 0 0 12.2 0l.5.4-2 1 1.3 2a19.8 19.8 0 0 0 6-3 20.2 20.2 0 0 0-3.6-13.7ZM8 15.4c-1.2 0-2.2-1.1-2.2-2.4S6.8 10.5 8 10.5s2.2 1.1 2.2 2.5c0 1.3-1 2.4-2.2 2.4Zm8 0c-1.2 0-2.2-1.1-2.2-2.4s1-2.5 2.2-2.5 2.2 1.1 2.2 2.5c0 1.3-1 2.4-2.2 2.4Z" />
</svg>
);
}
export function SlackIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M5 15.2a2.5 2.5 0 1 1-2.5-2.5H5v2.5Zm1.3 0a2.5 2.5 0 0 1 5 0v6.3a2.5 2.5 0 1 1-5 0v-6.3ZM8.8 5a2.5 2.5 0 1 1 2.5-2.5V5H8.8Zm0 1.3a2.5 2.5 0 0 1 0 5H2.5a2.5 2.5 0 1 1 0-5h6.3ZM19 8.8a2.5 2.5 0 1 1 2.5 2.5H19V8.8Zm-1.3 0a2.5 2.5 0 0 1-5 0V2.5a2.5 2.5 0 1 1 5 0v6.3ZM15.2 19a2.5 2.5 0 1 1-2.5 2.5V19h2.5Zm0-1.3a2.5 2.5 0 0 1 0-5h6.3a2.5 2.5 0 1 1 0 5h-6.3Z" />
</svg>
);
}
export function LinkedInIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M20.4 20.5h-3.6v-5.6c0-1.3 0-3-1.8-3s-2.1 1.4-2.1 2.9v5.7H9.3V9h3.4v1.6c.5-.9 1.6-1.8 3.4-1.8 3.6 0 4.3 2.4 4.3 5.5v6.2ZM5.3 7.4a2.1 2.1 0 1 1 0-4.1 2.1 2.1 0 0 1 0 4.1Zm1.8 13.1H3.6V9h3.5v11.5ZM22.2 0H1.8C.8 0 0 .8 0 1.7v20.6c0 .9.8 1.7 1.8 1.7h20.4c1 0 1.8-.8 1.8-1.7V1.7C24 .8 23.2 0 22.2 0Z" />
</svg>
);
}
export function YouTubeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2 31.4 31.4 0 0 0 0 12a31.4 31.4 0 0 0 .5 5.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31.4 31.4 0 0 0 24 12a31.4 31.4 0 0 0-.5-5.8ZM9.6 15.6V8.4l6.2 3.6-6.2 3.6Z" />
</svg>
);
}
export function AppleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.2-2.8.8-3.5.8s-1.8-.8-3-.8c-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.3 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7 2-1.1 2.7-2.2c.9-1.2 1.2-2.4 1.2-2.5-.1 0-2.3-.9-2.3-3.9ZM14.6 5.9c.6-.8 1.1-1.8.9-2.9-1 .1-2.1.6-2.8 1.4-.6.7-1.2 1.7-1 2.7 1.1.1 2.2-.5 2.9-1.2Z" />
</svg>
);
}
export function WindowsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
<path d="M3 5.2 10.6 4.1v7.1H3V5.2Zm8.4-1.2L21 2.6v8.6h-9.6V4Zm-8.4 8.2h7.6v7.2L3 18.3v-6.1Zm8.4 0H21v8.8l-9.6-1.3v-7.5Z" />
</svg>
);
}
export function LinuxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 304.998 304.998" fill="currentColor" aria-hidden {...props}>
<path d="M274.659,244.888c-8.944-3.663-12.77-8.524-12.4-15.777c0.381-8.466-4.422-14.667-6.703-17.117 c1.378-5.264,5.405-23.474,0.004-39.291c-5.804-16.93-23.524-42.787-41.808-68.204c-7.485-10.438-7.839-21.784-8.248-34.922 c-0.392-12.531-0.834-26.735-7.822-42.525C190.084,9.859,174.838,0,155.851,0c-11.295,0-22.889,3.53-31.811,9.684 c-18.27,12.609-15.855,40.1-14.257,58.291c0.219,2.491,0.425,4.844,0.545,6.853c1.064,17.816,0.096,27.206-1.17,30.06 c-0.819,1.865-4.851,7.173-9.118,12.793c-4.413,5.812-9.416,12.4-13.517,18.539c-4.893,7.387-8.843,18.678-12.663,29.597 c-2.795,7.99-5.435,15.537-8.005,20.047c-4.871,8.676-3.659,16.766-2.647,20.505c-1.844,1.281-4.508,3.803-6.757,8.557 c-2.718,5.8-8.233,8.917-19.701,11.122c-5.27,1.078-8.904,3.294-10.804,6.586c-2.765,4.791-1.259,10.811,0.115,14.925 c2.03,6.048,0.765,9.876-1.535,16.826c-0.53,1.604-1.131,3.42-1.74,5.423c-0.959,3.161-0.613,6.035,1.026,8.542 c4.331,6.621,16.969,8.956,29.979,10.492c7.768,0.922,16.27,4.029,24.493,7.035c8.057,2.944,16.388,5.989,23.961,6.913 c1.151,0.145,2.291,0.218,3.39,0.218c11.434,0,16.6-7.587,18.238-10.704c4.107-0.838,18.272-3.522,32.871-3.882 c14.576-0.416,28.679,2.462,32.674,3.357c1.256,2.404,4.567,7.895,9.845,10.724c2.901,1.586,6.938,2.495,11.073,2.495 c0.001,0,0,0,0.001,0c4.416,0,12.817-1.044,19.466-8.039c6.632-7.028,23.202-16,35.302-22.551c2.7-1.462,5.226-2.83,7.441-4.065 c6.797-3.768,10.506-9.152,10.175-14.771C282.445,250.905,279.356,246.811,274.659,244.888z M124.189,243.535 c-0.846-5.96-8.513-11.871-17.392-18.715c-7.26-5.597-15.489-11.94-17.756-17.312c-4.685-11.082-0.992-30.568,5.447-40.602 c3.182-5.024,5.781-12.643,8.295-20.011c2.714-7.956,5.521-16.182,8.66-19.783c4.971-5.622,9.565-16.561,10.379-25.182 c4.655,4.444,11.876,10.083,18.547,10.083c1.027,0,2.024-0.134,2.977-0.403c4.564-1.318,11.277-5.197,17.769-8.947 c5.597-3.234,12.499-7.222,15.096-7.585c4.453,6.394,30.328,63.655,32.972,82.044c2.092,14.55-0.118,26.578-1.229,31.289 c-0.894-0.122-1.96-0.221-3.08-0.221c-7.207,0-9.115,3.934-9.612,6.283c-1.278,6.103-1.413,25.618-1.427,30.003 c-2.606,3.311-15.785,18.903-34.706,21.706c-7.707,1.12-14.904,1.688-21.39,1.688c-5.544,0-9.082-0.428-10.551-0.651l-9.508-10.879 C121.429,254.489,125.177,250.583,124.189,243.535z M136.254,64.149c-0.297,0.128-0.589,0.265-0.876,0.411 c-0.029-0.644-0.096-1.297-0.199-1.952c-1.038-5.975-5-10.312-9.419-10.312c-0.327,0-0.656,0.025-1.017,0.08 c-2.629,0.438-4.691,2.413-5.821,5.213c0.991-6.144,4.472-10.693,8.602-10.693c4.85,0,8.947,6.536,8.947,14.272 C136.471,62.143,136.4,63.113,136.254,64.149z M173.94,68.756c0.444-1.414,0.684-2.944,0.684-4.532 c0-7.014-4.45-12.509-10.131-12.509c-5.552,0-10.069,5.611-10.069,12.509c0,0.47,0.023,0.941,0.067,1.411 c-0.294-0.113-0.581-0.223-0.861-0.329c-0.639-1.935-0.962-3.954-0.962-6.015c0-8.387,5.36-15.211,11.95-15.211 c6.589,0,11.95,6.824,11.95,15.211C176.568,62.78,175.605,66.11,173.94,68.756z M169.081,85.08 c-0.095,0.424-0.297,0.612-2.531,1.774c-1.128,0.587-2.532,1.318-4.289,2.388l-1.174,0.711c-4.718,2.86-15.765,9.559-18.764,9.952 c-2.037,0.274-3.297-0.516-6.13-2.441c-0.639-0.435-1.319-0.897-2.044-1.362c-5.107-3.351-8.392-7.042-8.763-8.485 c1.665-1.287,5.792-4.508,7.905-6.415c4.289-3.988,8.605-6.668,10.741-6.668c0.113,0,0.215,0.008,0.321,0.028 c2.51,0.443,8.701,2.914,13.223,4.718c2.09,0.834,3.895,1.554,5.165,2.01C166.742,82.664,168.828,84.422,169.081,85.08z M205.028,271.45c2.257-10.181,4.857-24.031,4.436-32.196c-0.097-1.855-0.261-3.874-0.42-5.826 c-0.297-3.65-0.738-9.075-0.283-10.684c0.09-0.042,0.19-0.078,0.301-0.109c0.019,4.668,1.033,13.979,8.479,17.226 c2.219,0.968,4.755,1.458,7.537,1.458c7.459,0,15.735-3.659,19.125-7.049c1.996-1.996,3.675-4.438,4.851-6.372 c0.257,0.753,0.415,1.737,0.332,3.005c-0.443,6.885,2.903,16.019,9.271,19.385l0.927,0.487c2.268,1.19,8.292,4.353,8.389,5.853 c-0.001,0.001-0.051,0.177-0.387,0.489c-1.509,1.379-6.82,4.091-11.956,6.714c-9.111,4.652-19.438,9.925-24.076,14.803 c-6.53,6.872-13.916,11.488-18.376,11.488c-0.537,0-1.026-0.068-1.461-0.206C206.873,288.406,202.886,281.417,205.028,271.45z M39.917,245.477c-0.494-2.312-0.884-4.137-0.465-5.905c0.304-1.31,6.771-2.714,9.533-3.313c3.883-0.843,7.899-1.714,10.525-3.308 c3.551-2.151,5.474-6.118,7.17-9.618c1.228-2.531,2.496-5.148,4.005-6.007c0.085-0.05,0.215-0.108,0.463-0.108 c2.827,0,8.759,5.943,12.177,11.262c0.867,1.341,2.473,4.028,4.331,7.139c5.557,9.298,13.166,22.033,17.14,26.301 c3.581,3.837,9.378,11.214,7.952,17.541c-1.044,4.909-6.602,8.901-7.913,9.784c-0.476,0.108-1.065,0.163-1.758,0.163 c-7.606,0-22.662-6.328-30.751-9.728l-1.197-0.503c-4.517-1.894-11.891-3.087-19.022-4.241c-5.674-0.919-13.444-2.176-14.732-3.312 c-1.044-1.171,0.167-4.978,1.235-8.337c0.769-2.414,1.563-4.91,1.998-7.523C41.225,251.596,40.499,248.203,39.917,245.477z" />
</svg>
);
}
export function AndroidIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 512 512" fill="currentColor" aria-hidden {...props}>
<path d="M120.606,169h270.788v220.663c0,13.109-10.628,23.737-23.721,23.737h-27.123v67.203 c0,17.066-13.612,30.897-30.415,30.897c-16.846,0-30.438-13.831-30.438-30.897v-67.203h-47.371v67.203 c0,17.066-13.639,30.897-30.441,30.897c-16.799,0-30.437-13.831-30.437-30.897v-67.203h-27.099 c-13.096,0-23.744-10.628-23.744-23.737V169z M67.541,167.199c-16.974,0-30.723,13.963-30.723,31.2v121.937 c0,17.217,13.749,31.204,30.723,31.204c16.977,0,30.723-13.987,30.723-31.204V198.399 C98.264,181.162,84.518,167.199,67.541,167.199z M391.395,146.764H120.606c3.342-38.578,28.367-71.776,64.392-90.998 l-25.746-37.804c-3.472-5.098-2.162-12.054,2.946-15.525c5.102-3.471,12.044-2.151,15.533,2.943l28.061,41.232 c15.558-5.38,32.446-8.469,50.208-8.469c17.783,0,34.672,3.089,50.229,8.476L334.29,5.395c3.446-5.108,10.41-6.428,15.512-2.957 c5.108,3.471,6.418,10.427,2.946,15.525l-25.725,37.804C363.047,74.977,388.055,108.175,391.395,146.764z M213.865,94.345 c0-8.273-6.699-14.983-14.969-14.983c-8.291,0-14.99,6.71-14.99,14.983c0,8.269,6.721,14.976,14.99,14.976 S213.865,102.614,213.865,94.345z M329.992,94.345c0-8.273-6.722-14.983-14.99-14.983c-8.291,0-14.97,6.71-14.97,14.983 c0,8.269,6.679,14.976,14.97,14.976C323.271,109.321,329.992,102.614,329.992,94.345z M444.48,167.156 c-16.956,0-30.744,13.984-30.744,31.222v121.98c0,17.238,13.788,31.226,30.744,31.226c16.978,0,30.701-13.987,30.701-31.226 v-121.98C475.182,181.14,461.458,167.156,444.48,167.156z" />
</svg>
);
}
function PostgresIcon(props: SVGProps<SVGSVGElement>) {
return <Database {...props} />;
}
/** Font Awesome names used by the Mintlify content -> equivalent icon */
const icons: Record<string, IconComponent> = {
plug: Plug,
link: Link,
desktop: Monitor,
display: Monitor,
sparkles: Sparkles,
building: Building2,
'user-group': Users,
users: Users,
user: User,
'id-card': IdCard,
'chart-bar': ChartColumn,
'chart-simple': ChartColumn,
'chart-line': ChartLine,
bell: Bell,
'file-code': FileCode,
'circle-nodes': Waypoints,
'code-branch': GitBranch,
server: Server,
'circle-question': CircleHelp,
question: CircleHelp,
sliders: SlidersHorizontal,
box: Box,
cube: Box,
cubes: Boxes,
'boxes-stacked': Boxes,
'layer-group': Layers,
route: Route,
'list-check': ListChecks,
globe: Globe,
shield: Shield,
'network-wired': Network,
sitemap: Network,
'diagram-project': Workflow,
cloud: Cloud,
book: BookOpen,
download: Download,
database: Database,
postgres: PostgresIcon,
terminal: Terminal,
'table-list': TableProperties,
robot: Bot,
lock: Lock,
key: Key,
hashtag: Hash,
'grid-2': LayoutGrid,
envelope: Mail,
'credit-card': CreditCard,
check: Check,
certificate: BadgeCheck,
bucket: PaintBucket,
brush: Brush,
brain: Brain,
bolt: Bolt,
x: X,
github: GitHubIcon,
discord: DiscordIcon,
slack: SlackIcon,
linkedin: LinkedInIcon,
youtube: YouTubeIcon,
};
export function Icon({ name, ...props }: { name?: string } & SVGProps<SVGSVGElement>) {
if (!name) return null;
const Component = icons[name];
if (!Component) {
if (process.env.NODE_ENV === 'development') console.warn(`[icons] no mapping for "${name}"`);
return null;
}
return <Component {...(props as object)} />;
}
export function hasIcon(name?: string) {
return !!name && name in icons;
}
+116
View File
@@ -0,0 +1,116 @@
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import {
Children,
type ComponentProps,
type ReactElement,
type ReactNode,
Suspense,
use,
useDeferredValue,
} from 'react';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { visit } from 'unist-util-visit';
import type { ElementContent, Root, RootContent } from 'hast';
export interface Processor {
process: (content: string) => Promise<ReactNode>;
}
export function rehypeWrapWords() {
return (tree: Root) => {
visit(tree, ['text', 'element'], (node, index, parent) => {
if (node.type === 'element' && node.tagName === 'pre') return 'skip';
if (node.type !== 'text' || !parent || index === undefined) return;
const words = node.value.split(/(?=\s)/);
// Create new span nodes for each word and whitespace
const newNodes: ElementContent[] = words.flatMap((word) => {
if (word.length === 0) return [];
return {
type: 'element',
tagName: 'span',
properties: {
class: 'animate-fd-fade-in',
},
children: [{ type: 'text', value: word }],
};
});
Object.assign(node, {
type: 'element',
tagName: 'span',
properties: {},
children: newNodes,
} satisfies RootContent);
return 'skip';
});
};
}
function createProcessor(): Processor {
const processor = remark().use(remarkGfm).use(remarkRehype).use(rehypeWrapWords);
return {
async process(content) {
const nodes = processor.parse({ value: content });
const hast = await processor.run(nodes);
return toJsxRuntime(hast, {
development: false,
jsx,
jsxs,
Fragment,
components: {
...defaultMdxComponents,
pre: Pre,
img: undefined, // use JSX
},
});
},
};
}
function Pre(props: ComponentProps<'pre'>) {
const code = Children.only(props.children) as ReactElement;
const codeProps = code.props as ComponentProps<'code'>;
const content = codeProps.children;
if (typeof content !== 'string') return null;
let lang =
codeProps.className
?.split(' ')
.find((v) => v.startsWith('language-'))
?.slice('language-'.length) ?? 'text';
if (lang === 'mdx') lang = 'md';
return <DynamicCodeBlock lang={lang} code={content.trimEnd()} />;
}
const processor = createProcessor();
export function Markdown({ text }: { text: string }) {
const deferredText = useDeferredValue(text);
return (
<Suspense fallback={<p className="invisible">{text}</p>}>
<Renderer text={deferredText} />
</Suspense>
);
}
const cache = new Map<string, Promise<ReactNode>>();
function Renderer({ text }: { text: string }) {
const result = cache.get(text) ?? processor.process(text);
cache.set(text, result);
return use(result);
}
+17
View File
@@ -0,0 +1,17 @@
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { mintlifyComponents } from './mintlify';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
...mintlifyComponents,
...components,
} satisfies MDXComponents;
}
export const useMDXComponents = getMDXComponents;
declare global {
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
}
+439
View File
@@ -0,0 +1,439 @@
/**
* Drop-in replacements for the Mintlify MDX components used across the docs, so the
* content could be ported without rewriting pages. Styling lives in `app/global.css`
* under the `pg-*` class names.
*/
import {
Children,
isValidElement,
type ComponentProps,
type ReactElement,
type ReactNode,
} from 'react';
import Link from 'fumadocs-core/link';
import { Tab as FumaTab, Tabs as FumaTabs } from 'fumadocs-ui/components/tabs';
import {
ArrowRight,
ChevronRight,
CircleAlert,
CircleCheck,
Info as InfoIcon,
Lightbulb,
OctagonAlert,
StickyNote,
} from 'lucide-react';
import { cn } from '@/lib/cn';
import { Icon } from './icons';
/* -------------------------------------------------------------------------- */
/* Callouts */
/* -------------------------------------------------------------------------- */
type CalloutType = 'note' | 'info' | 'tip' | 'warning' | 'check' | 'danger';
const calloutIcons: Record<CalloutType, typeof InfoIcon> = {
note: StickyNote,
info: InfoIcon,
tip: Lightbulb,
warning: CircleAlert,
check: CircleCheck,
danger: OctagonAlert,
};
function Callout({
type,
title,
icon,
children,
}: {
type: CalloutType;
title?: ReactNode;
icon?: string;
children?: ReactNode;
}) {
const DefaultIcon = calloutIcons[type];
return (
<div className="pg-callout not-prose" data-type={type} role="note">
<span className="pg-callout-icon">
{icon ? <Icon name={icon} /> : <DefaultIcon />}
</span>
<div className="pg-callout-body prose">
{title && <p className="font-medium">{title}</p>}
{children}
</div>
</div>
);
}
type CalloutProps = { title?: ReactNode; icon?: string; children?: ReactNode };
export const Note = (p: CalloutProps) => <Callout type="note" {...p} />;
export const Info = (p: CalloutProps) => <Callout type="info" {...p} />;
export const Tip = (p: CalloutProps) => <Callout type="tip" {...p} />;
export const Warning = (p: CalloutProps) => <Callout type="warning" {...p} />;
export const Check = (p: CalloutProps) => <Callout type="check" {...p} />;
export const Danger = (p: CalloutProps) => <Callout type="danger" {...p} />;
/* -------------------------------------------------------------------------- */
/* Cards */
/* -------------------------------------------------------------------------- */
export function Card({
title,
icon,
href,
arrow,
cta,
horizontal,
img,
children,
}: {
title?: ReactNode;
icon?: string;
href?: string;
arrow?: boolean | string;
cta?: string;
horizontal?: boolean;
img?: string;
children?: ReactNode;
}) {
const external = href ? /^https?:\/\//.test(href) : false;
const showArrow = arrow === true || arrow === 'true' || (arrow === undefined && external);
const content = (
<>
{img && <img src={img} alt="" className="pg-card-img" />}
<div className={cn('pg-card-inner', horizontal && 'pg-card-horizontal')}>
{icon && (
<span className="pg-card-icon">
<Icon name={icon} />
</span>
)}
<div className="min-w-0 flex-1">
{title && (
<p className="pg-card-title">
{title}
{showArrow && <ArrowRight className="pg-card-arrow" />}
</p>
)}
{children && <div className="pg-card-content prose">{children}</div>}
{cta && (
<p className="pg-card-cta">
{cta}
<ChevronRight className="size-3.5" />
</p>
)}
</div>
</div>
</>
);
if (!href) return <div className="pg-card not-prose">{content}</div>;
// A full-card overlay link instead of wrapping the card in <a>: card bodies can contain
// their own links, and nested <a> elements are invalid HTML (hydration errors).
return (
<div className="pg-card pg-card-link not-prose" data-card="">
<Link
href={href}
external={external}
className="pg-card-overlay"
aria-label={typeof title === 'string' ? title : cta}
/>
{content}
</div>
);
}
export function CardGroup({ cols = 2, children }: { cols?: number; children?: ReactNode }) {
return (
<div className="pg-card-group not-prose" style={{ '--pg-cols': cols } as React.CSSProperties}>
{children}
</div>
);
}
export const Columns = CardGroup;
/* -------------------------------------------------------------------------- */
/* Steps */
/* -------------------------------------------------------------------------- */
export function Steps({ children }: { children?: ReactNode }) {
return <div className="pg-steps">{children}</div>;
}
export function Step({
title,
icon,
children,
}: {
title?: ReactNode;
icon?: string;
stepNumber?: number;
titleSize?: string;
children?: ReactNode;
}) {
return (
<div className="pg-step">
<span className="pg-step-marker" aria-hidden>
{icon ? <Icon name={icon} className="size-3.5" /> : null}
</span>
{title && <p className="pg-step-title">{title}</p>}
<div className="pg-step-content">{children}</div>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Tabs */
/* -------------------------------------------------------------------------- */
type TabElement = ReactElement<{ title?: string; children?: ReactNode }>;
export function Tabs({ children }: { children?: ReactNode }) {
const tabs = Children.toArray(children).filter(
(child): child is TabElement => isValidElement(child),
);
const items = tabs.map((tab, i) => tab.props.title ?? `Tab ${i + 1}`);
return (
<FumaTabs items={items}>
{tabs.map((tab, i) => (
<FumaTab key={items[i]} value={items[i]}>
{tab.props.children}
</FumaTab>
))}
</FumaTabs>
);
}
/** Only rendered through <Tabs>, which reads its props directly. */
export function Tab({ children }: { title?: string; children?: ReactNode }) {
return <>{children}</>;
}
/* -------------------------------------------------------------------------- */
/* Accordions & Expandables */
/* -------------------------------------------------------------------------- */
function slugify(value: unknown) {
return typeof value === 'string'
? value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
: undefined;
}
export function Accordion({
title,
description,
icon,
defaultOpen,
children,
}: {
title: ReactNode;
description?: ReactNode;
icon?: string;
defaultOpen?: boolean;
children?: ReactNode;
}) {
return (
<details className="pg-accordion" open={defaultOpen} id={slugify(title)}>
<summary>
<ChevronRight className="pg-accordion-chevron" />
{icon && <Icon name={icon} className="size-4 shrink-0 text-fd-muted-foreground" />}
<span className="flex-1">
<span className="font-medium">{title}</span>
{description && (
<span className="block text-sm text-fd-muted-foreground">{description}</span>
)}
</span>
</summary>
<div className="pg-accordion-content prose">{children}</div>
</details>
);
}
export function AccordionGroup({ children }: { children?: ReactNode }) {
return <div className="pg-accordion-group">{children}</div>;
}
export function Expandable({
title = 'properties',
defaultOpen,
children,
}: {
title?: string;
defaultOpen?: boolean;
children?: ReactNode;
}) {
return (
<details className="pg-expandable" open={defaultOpen}>
<summary>
<ChevronRight className="pg-accordion-chevron" />
<span className="pg-expandable-closed">Show {title}</span>
<span className="pg-expandable-open">Hide {title}</span>
</summary>
<div className="pg-expandable-content">{children}</div>
</details>
);
}
/* -------------------------------------------------------------------------- */
/* API fields */
/* -------------------------------------------------------------------------- */
export function ResponseField({
name,
type,
required,
default: defaultValue,
deprecated,
post,
pre,
children,
}: {
name: string;
type?: string;
required?: boolean;
default?: unknown;
deprecated?: boolean;
post?: string[];
pre?: string[];
children?: ReactNode;
}) {
const id = slugify(name);
return (
<div className="pg-field" id={id ? `field-${id}` : undefined}>
<div className="pg-field-header">
{pre?.map((p) => (
<span key={p} className="pg-field-pill">
{p}
</span>
))}
<code className="pg-field-name">{name}</code>
{type && <span className="pg-field-type">{type}</span>}
{defaultValue !== undefined && (
<span className="pg-field-pill">
default: <code>{String(defaultValue)}</code>
</span>
)}
{post?.map((p) => (
<span key={p} className="pg-field-pill">
{p}
</span>
))}
{required && <span className="pg-field-required">required</span>}
{deprecated && <span className="pg-field-deprecated">deprecated</span>}
</div>
{children && <div className="pg-field-body prose">{children}</div>}
</div>
);
}
export const ParamField = ResponseField;
/* -------------------------------------------------------------------------- */
/* Media */
/* -------------------------------------------------------------------------- */
export function Frame({
caption,
hint,
children,
}: {
caption?: ReactNode;
hint?: ReactNode;
children?: ReactNode;
}) {
return (
<figure className="pg-frame not-prose">
<div className="pg-frame-inner">
{hint && <p className="pg-frame-hint">{hint}</p>}
{children}
{caption && <figcaption>{caption}</figcaption>}
</div>
</figure>
);
}
export function Iframe(props: ComponentProps<'iframe'>) {
return (
<Frame>
<iframe {...props} />
</Frame>
);
}
export function Video(props: ComponentProps<'video'>) {
return (
<Frame>
<video {...props} />
</Frame>
);
}
/** plain <img>: content images are referenced by absolute `/images/...` paths */
export function Img({
centered: _centered,
noZoom: _noZoom,
...props
}: ComponentProps<'img'> & { centered?: boolean; noZoom?: boolean }) {
return <img loading="lazy" {...props} alt={props.alt ?? ''} />;
}
/* -------------------------------------------------------------------------- */
/* Misc */
/* -------------------------------------------------------------------------- */
export function Update({
label,
description,
children,
}: {
label: string;
description?: string;
children?: ReactNode;
}) {
return (
<div className="pg-update" id={slugify(label)}>
<div className="pg-update-meta">
<span className="pg-field-pill">{label}</span>
{description && <p className="text-sm text-fd-muted-foreground">{description}</p>}
</div>
<div className="prose">{children}</div>
</div>
);
}
export function MintLink(props: ComponentProps<'a'>) {
const external = props.href ? /^https?:\/\//.test(props.href) : false;
return <Link {...props} href={props.href ?? '#'} external={external} />;
}
export const mintlifyComponents = {
Note,
Info,
Tip,
Warning,
Check,
Danger,
Card,
CardGroup,
Columns,
Steps,
Step,
Tabs,
Tab,
Accordion,
AccordionGroup,
Expandable,
ResponseField,
ParamField,
Frame,
iframe: Iframe,
video: Video,
Update,
Link: MintLink,
img: Img,
};
+36
View File
@@ -0,0 +1,36 @@
'use client';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/cn';
import navigation from '@/lib/navigation.json';
import { Icon } from './icons';
/**
* Sidebar header with the global anchors (Discord, Slack) from `lib/navigation.json`,
* styled like Mintlify's anchors. Rendered through the sidebar `banner` slot as a
* component so it owns the header padding; the bottom spacing matches the gap between
* sidebar groups.
*/
export function SidebarAnchors({ children, className, ...props }: ComponentProps<'div'>) {
return (
<div {...props} className={cn('flex flex-col px-4 pt-4', className)}>
{children}
<ul className="flex flex-col gap-1 max-lg:mt-4">
{navigation.anchors.map((anchor) => (
<li key={anchor.href}>
<a
href={anchor.href}
target="_blank"
rel="noreferrer noopener"
className="pg-anchor"
>
<span className="pg-anchor-icon">
<Icon name={anchor.icon ?? undefined} />
</span>
{anchor.label}
</a>
</li>
))}
</ul>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import {
AndroidIcon,
AppleIcon,
GitHubIcon,
LinuxIcon,
LinkedInIcon,
WindowsIcon,
YouTubeIcon,
} from './icons';
import { Logo } from '@/lib/layout.shared';
const downloads = [
{ label: 'macOS', href: 'https://pangolin.net/downloads/mac', icon: AppleIcon },
{ label: 'iOS', href: 'https://pangolin.net/downloads/ios', icon: AppleIcon },
{ label: 'Windows', href: 'https://pangolin.net/downloads/windows', icon: WindowsIcon },
{ label: 'Android', href: 'https://pangolin.net/downloads/android', icon: AndroidIcon },
{ label: 'Linux', href: 'https://pangolin.net/downloads/linux', icon: LinuxIcon },
];
const socials = [
{ label: 'GitHub', href: 'https://github.com/fosrl/pangolin', icon: GitHubIcon },
{ label: 'LinkedIn', href: 'https://linkedin.com/company/pangolin-net', icon: LinkedInIcon },
{ label: 'YouTube', href: 'https://youtube.com/@pangolin-net', icon: YouTubeIcon },
];
export function SiteFooter() {
const year = new Date().getFullYear();
return (
<footer className="pg-footer">
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-4">
<div className="flex items-center gap-4">
<Logo />
<span className="h-6 w-px bg-fd-border" aria-hidden />
<div className="flex gap-3">
{socials.map(({ label, href, icon: Icon }) => (
<a
key={label}
href={href}
aria-label={label}
target="_blank"
rel="noreferrer noopener"
className="text-fd-muted-foreground transition-colors hover:text-fd-foreground"
>
<Icon className="size-4.5" />
</a>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{downloads.map(({ label, href, icon: Icon }) => (
<a key={label} href={href} className="pg-download">
<Icon className="size-4" />
{label}
</a>
))}
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 text-sm text-fd-muted-foreground">
<a
href="https://status.pangolin.net"
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-2 text-fd-muted-foreground no-underline transition-colors hover:text-fd-foreground"
>
<span className="size-2 shrink-0 rounded-full bg-[#2d8a4e]" aria-hidden />
All systems operational
</a>
<p>© {year} Fossorial Inc.</p>
</div>
</footer>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { cva, type VariantProps } from 'class-variance-authority';
const primary = 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80';
const variants = {
default: primary,
primary,
outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground',
ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
secondary:
'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
} as const;
export const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
{
variants: {
variant: variants,
// `color` and `primary` predate the Shadcn UI compatible names
color: variants,
size: {
sm: 'gap-1 px-2 py-1.5 text-xs',
icon: 'p-1.5 [&_svg]:size-5',
'icon-sm': 'p-1.5 [&_svg]:size-4.5',
'icon-xs': 'p-1 [&_svg]:size-4',
},
},
},
);
export type ButtonProps = VariantProps<typeof buttonVariants>;
@@ -3,8 +3,6 @@ title: "Pangolin vs. Proxy vs. VPN"
description: "What are the similarities and differences between Pangolin and traditional reverse proxies and VPNs?"
---
Pangolin combines the capabilities of both a reverse proxy and a VPN into a single platform. It provides reverse proxy functionality through public resources and VPN functionality through private resources, all with zero-trust access control and distributed architecture.
## What Each Solution Provides
@@ -11,11 +11,6 @@ We build in the open and are self‑hosted by default so teams retain control ov
If you're interested in open-source auth and networking infrastructure, we'd love to chat.
## Open Engineering Roles
## Open Roles
- [Software Engineer - Full Stack](./software-engineer-full-stack)
- [Software Engineer - Backend](./software-engineer-backend)
- [Software Engineer - Frontend](./software-engineer-frontend)
- [Software Lead](./software-engineer-lead)
- [Software Engineer - Networking](./software-engineer-networking)
- [Software Engineer - Go](./software-engineer-go)
- [Software Engineer - Full Stack](./software-engineer-full-stack)
@@ -1,7 +1,7 @@
---
title: "Software Engineer - Full Stack"
---
- Location: `New York City`
- Location: `San Francisco`
- Salary: `$125k - $185k + 0.5% - 1.5% equity`
- Years of experience: `3+`
- Skills: `TypeScript, Go, SQL (PostgreSQL, SQLite), NextJS, AWS`
@@ -47,7 +47,7 @@ As a Full Stack Software Engineer at Pangolin, you'll help architect, build, and
## What You Can Expect
- Competitive salary
- In-person (New York City)
- Hybrid (in-person + work-from-home)
- Quiet work environment
- Small, trusting team of founders and engineers
- Relocation assistance
@@ -63,6 +63,6 @@ As a Full Stack Software Engineer at Pangolin, you'll help architect, build, and
## How to Apply
1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net)
2. Include your GitHub profile and/or any relevant open-source contributions
3. Include a brief thoughtful message about why you're interested in Pangolin and this role
1. [Apply here](https://www.ycombinator.com/companies/pangolin/jobs/7DeBchl-founding-engineer-full-stack)
2. Attach your GitHub profile and/or any relevant open-source contributions
3. Include a brief thoughtful message about why you're interested in Pangolin and this role
@@ -1,7 +1,7 @@
---
title: "Software Engineer - Go + Network"
---
- Location: `New York City`
- Location: `San Francisco`
- Salary: `$125k - $185k + 0.5% - 1.5% equity`
- Years of experience: `3+`
- Skills: `Go, Networking Fundamentals, TypeScript (yes for coordination)`
@@ -44,7 +44,7 @@ As a Go Network Software Engineer at Pangolin, you'll help architect, build, and
## What You Can Expect
- Competitive salary
- In-person (New York City)
- Hybrid (in-person + work-from-home)
- Quiet work environment
- Small, trusting team of founders and engineers
- Relocation assistance
@@ -60,6 +60,6 @@ As a Go Network Software Engineer at Pangolin, you'll help architect, build, and
## How to Apply
1. Email your resume/CV to [careers@pangolin.net](mailto:careers@pangolin.net)
2. Include your GitHub profile and highlight any past projects
3. Include a brief thoughtful message about why you're interested in Pangolin and this role
1. Add [Owen](https://www.linkedin.com/in/owenschwartz/) on LinkedIn
2. Send your resume/CV
3. Send your GitHub profile and highlight any past projects
@@ -3,8 +3,6 @@ title: "Users and Roles"
description: "Add internal or external users to your organization and manage roles"
---
## Users in Organizations
Users can be added to organizations. When a user is added to Pangolin, there is a global user object and an organization‑specific user object that links that user to the organization. This allows a user to exist in one or more organizations.
@@ -16,7 +14,7 @@ Because the global user exists and a per‑organization user exists, a user invi
When removing a user from an organization, their account still exists. To completely delete their account, visit the server admin panel as the server admin and delete the global user in the users table.
<Frame>
<img src="/images/users-table.png" alt="Users table in the Pangolin dashboard" centered/>
<img src="/images/users-table.png" alt="Users table in the Pangolin dashboard"/>
</Frame>
### Internal Users
@@ -3,8 +3,6 @@ title: "Shareable Links"
description: "Create Links and use access tokens for browser or programmatic access."
---
Links are special URLs that grant access to one resource without requiring the recipient to sign in as a Pangolin user. Anyone with a web browser on the internet can access the resource if they have a valid Link.
When you create a Link, Pangolin gives you two ways to use it:
@@ -3,8 +3,6 @@ title: "Claude Desktop"
description: "Connect Claude Desktop's third-party inference to a Pangolin AI Gateway resource"
---
Claude Desktop can route its requests through a third-party inference gateway instead of Anthropic directly. Point it at an AI Gateway resource that has an Anthropic-compatible provider attached — Anthropic itself, Bedrock, Vertex AI, or a custom Anthropic-compatible endpoint. See [AI Gateway Overview](/manage/ai/overview) if you haven't set that up yet.
<Frame>
@@ -14,11 +12,10 @@ Claude Desktop can route its requests through a third-party inference gateway in
/>
</Frame>
import AiGatewayKey from "/snippets/ai-gateway-key.mdx";
You'll need the resource's URL (its `<endpoint>`) and its API key (`<key>`). Both are on the resource's Keys page.
<AiGatewayKey />
<include>../../../../snippets/ai-gateway-key.mdx</include>
## Steps
@@ -3,15 +3,12 @@ title: "Claude Code"
description: "Connect Claude Code to a Pangolin AI Gateway resource"
---
Claude Code talks the Anthropic Messages API, so the resource you point it at needs an Anthropic-compatible provider attached - Anthropic itself, Bedrock, Vertex AI, or a custom Anthropic-compatible endpoint like Kimi K2. See [AI Gateway Overview](/manage/ai/overview) if you haven't set that up yet.
import AiGatewayKey from "/snippets/ai-gateway-key.mdx";
You'll need the resource's URL (its `<endpoint>`) and its API key (`<key>`). Both are on the resource's Keys page.
<AiGatewayKey />
<include>../../../../snippets/ai-gateway-key.mdx</include>
## Fastest: Pangolin CLI
@@ -3,15 +3,12 @@ title: "Codex"
description: "Connect Codex to a Pangolin AI Gateway resource"
---
Codex talks the OpenAI API, so the resource you point it at needs an OpenAI-compatible provider attached — OpenAI, OpenRouter, Vercel AI Gateway, Microsoft Foundry, or a custom OpenAI-compatible endpoint. See [AI Gateway Overview](/manage/ai/overview) if you haven't set that up yet.
import AiGatewayKey from "/snippets/ai-gateway-key.mdx";
You'll need the resource's URL (its `<endpoint>`) and its API key (`<key>`). Both are on the resource's Keys page.
<AiGatewayKey />
<include>../../../../snippets/ai-gateway-key.mdx</include>
## Fastest: Pangolin CLI
@@ -3,15 +3,12 @@ title: "Gemini CLI"
description: "Connect Gemini CLI to a Pangolin AI Gateway resource"
---
Gemini CLI talks Google's native `generateContent` API, so the resource you point it at needs a Google Gemini (or Vertex AI) provider attached. See [AI Gateway Overview](/manage/ai/overview) if you haven't set that up yet.
import AiGatewayKey from "/snippets/ai-gateway-key.mdx";
You'll need the resource's URL (its `<endpoint>`) and its API key (`<key>`). Both are on the resource's Keys page.
<AiGatewayKey />
<include>../../../../snippets/ai-gateway-key.mdx</include>
## Fastest: Pangolin CLI
@@ -3,15 +3,12 @@ title: "Open WebUI"
description: "Connect Open WebUI to a Pangolin AI Gateway resource"
---
Open WebUI is a self-hosted chat interface. It talks the OpenAI API, so point it at an AI Gateway resource that has an OpenAI-compatible provider attached (OpenAI, OpenRouter, Vercel AI Gateway, Microsoft Foundry, or custom). See [AI Gateway Overview](/manage/ai/overview) if you haven't set that up yet.
import AiGatewayKey from "/snippets/ai-gateway-key.mdx";
You'll need the resource's URL (its `<endpoint>`) and its API key (`<key>`). Both are on the resource's Keys page.
<AiGatewayKey />
<include>../../../../snippets/ai-gateway-key.mdx</include>
## If you're running Open WebUI for the first time
@@ -3,15 +3,12 @@ title: "OpenClaw"
description: "Connect OpenClaw to a Pangolin AI Gateway resource"
---
[OpenClaw](https://openclaw.ai) is an open-source agent gateway that can run against any OpenAI- or Anthropic-compatible endpoint. Point it at an AI Gateway resource with a matching provider attached - OpenAI-compatible (OpenAI, OpenRouter, Vercel AI Gateway, Microsoft Foundry, custom) or Anthropic-compatible (Anthropic, Bedrock, Vertex AI, custom). See [AI Gateway Overview](/manage/ai/overview) if you haven't set that up yet.
import AiGatewayKey from "/snippets/ai-gateway-key.mdx";
You'll need the resource's URL (its `<endpoint>`) and its API key (`<key>`), both on the resource's Keys page, plus the name of a model your attached provider actually serves (e.g. `gpt-4o`, `claude-sonnet-4-6`) - that's `<model-id>` below.
<AiGatewayKey />
<include>../../../../snippets/ai-gateway-key.mdx</include>
## Install
@@ -3,15 +3,12 @@ title: "OpenCode"
description: "Connect OpenCode to a Pangolin AI Gateway resource"
---
OpenCode configures each model provider separately, so it can talk to whichever API formats your resource supports — Anthropic Messages, OpenAI Chat/Responses, or both, depending on which providers are attached. See [AI Gateway Overview](/manage/ai/overview) if you haven't set that up yet.
import AiGatewayKey from "/snippets/ai-gateway-key.mdx";
You'll need the resource's URL (its `<endpoint>`) and its API key (`<key>`). Both are on the resource's Keys page.
<AiGatewayKey />
<include>../../../../snippets/ai-gateway-key.mdx</include>
## Fastest: Pangolin CLI
@@ -26,7 +26,7 @@ Create an org-level provider so the gateway has an upstream to call. Sidebar →
Resources → **Create** → set **Type** to **AI Gateway**, pick a domain, and attach the provider(s) from step 1. You can also attach providers to an existing resource later from its **AI Gateway** settings tab. How public and private AI Gateway resources reach users is covered on the [public](/manage/resources/public/ai-gateway) and [private](/manage/resources/private/ai-gateway) resource type pages.
<Frame>
<img src="/images/ai/create-ai-gateway-resource.png" alt="Create Public Resource form with Type set to AI Gateway" centered />
<img src="/images/ai/create-ai-gateway-resource.png" alt="Create Public Resource form with Type set to AI Gateway" />
</Frame>
You can create more than one AI Gateway resource so different users and roles get their own providers. Distinct hostnames are the usual approach; they can also share a FQDN. See [Multiple Gateway Resources](/manage/ai/multiple-gateway-resources).
@@ -12,7 +12,7 @@ See [AI Providers](/manage/ai/providers/overview) for what a provider is, and [P
On the provider's **Models** tab, every request must match an **allow** entry and must not match a **block** entry. An empty allow list denies all traffic. A key cannot sit on both lists.
<Frame>
<img src="/images/ai/public-resource-models.png" alt="Provider Models tab with allow and block lists for Anthropic" centered />
<img src="/images/ai/public-resource-models.png" alt="Provider Models tab with allow and block lists for Anthropic" />
</Frame>
You add keys three ways:
@@ -27,7 +27,7 @@ On an AI Gateway resource, attach one or more providers from the **AI Gateway**
The resource only speaks the API formats its attached providers advertise. Attach whichever providers match the [clients](/manage/ai/overview#4-connect-a-client) you plan to connect.
<Frame>
<img src="/images/ai/public-resource-providers.png" alt="AI Gateway resource settings with attached providers" centered />
<img src="/images/ai/public-resource-providers.png" alt="AI Gateway resource settings with attached providers" />
</Frame>
## Provider Types
@@ -6,7 +6,7 @@ description: "Prompt and response transcripts for AI Gateway requests in an orga
Session logs store the prompt and response for each AI Gateway call. Open **AI Gateway > Session Logs** to browse them. Use them to see what a client sent, what the upstream returned, and which user, key, provider, and resource handled the call.
<Frame>
<img src="/images/ai/session-logs.png" alt="AI Gateway session logs table in the Pangolin dashboard" centered />
<img src="/images/ai/session-logs.png" alt="AI Gateway session logs table in the Pangolin dashboard" />
</Frame>
<Note>
@@ -6,7 +6,7 @@ description: "Cost, token usage, and request volume across providers, resources,
Usage analytics rolls up every AI Gateway call: how many requests ran, how many tokens they used, and estimated USD. Open **AI Gateway → Usage Analytics**. The numbers come from the same recorded usage that [budgets](/manage/ai/budgets) enforce.
<Frame>
<img src="/images/ai/analytics-overview.png" alt="Usage analytics overview with cost, token, and request charts" centered />
<img src="/images/ai/analytics-overview.png" alt="Usage analytics overview with cost, token, and request charts" />
</Frame>
These records are independent of session log retention. For prompt and response text, see [Session Logs](/manage/ai/session-logs). The two share a session id per call.
@@ -56,7 +56,7 @@ You can optionally associate a user. That association is for usage tracking and
When you create or edit a key, you can attach a [budget](/manage/ai/budgets) so spend or tokens on that key are capped.
<Frame>
<img src="/images/ai/create-virtual-api-key.png" alt="Create Virtual API Key dialog with name, description, and public AI gateway access" centered />
<img src="/images/ai/create-virtual-api-key.png" alt="Create Virtual API Key dialog with name, description, and public AI gateway access" />
</Frame>
## Where to Get a Key
@@ -3,8 +3,6 @@ title: "Alert Rules"
description: "Subscribe to Pangolin events on sites, resources, and health checks and deliver email, webhooks, or integrations"
---
<Note>
Only available in [Pangolin Cloud](https://app.pangolin.net/auth/signup) and [Enterprise Edition](/self-host/enterprise-edition).
</Note>
@@ -3,8 +3,6 @@ title: "Health Checks"
description: "Monitor reachability and response for public resource targets and arbitrary endpoints from your sites"
---
A health check is a periodic probe that decides whether something on your network is up and responding the way you expect. Pangolin runs these checks from your sites so they reflect reachability from the connector’s perspective, not only from wherever an administrator happens to be.
## Health Checks on Public Resource Targets
@@ -19,7 +19,7 @@ Authentication logs capture authentication events when users or API keys attempt
- Analyzing user agent and device information
<Frame>
<img src="/images/access_logs.png" alt="Authentication logs table in the Pangolin dashboard" centered/>
<img src="/images/access_logs.png" alt="Authentication logs table in the Pangolin dashboard"/>
</Frame>
<Tip>Make sure to enable authentication logs in the org settings</Tip>
@@ -19,7 +19,7 @@ Admin Action logs capture administrative events and configuration changes perfor
- Meeting security and compliance requirements
<Frame>
<img src="/images/action_logs.png" alt="Admin action logs table in the Pangolin dashboard" centered/>
<img src="/images/action_logs.png" alt="Admin action logs table in the Pangolin dashboard"/>
</Frame>
<Tip>Make sure to enable access logs in the org settings</Tip>
@@ -3,8 +3,6 @@ title: "Network Logs"
description: "Network logs are a record of TCP and UDP sessions between clients and private resources on sites"
---
Network logs record each TCP and UDP session that traverses the tunnel between Pangolin clients and resources on your sites. They apply to private resources reached through the Pangolin client (and related tunnel traffic), not to public resources served only through the reverse proxy. You can see which clients and users opened sessions to which private resources, the source and destination addresses and protocols (TCP and UDP), the start and end times of the sessions, and more.
<Note>
@@ -16,7 +16,7 @@ HTTPS Request logs capture every HTTPS request that passes through a reverse pro
- Troubleshooting connectivity and routing issues
<Frame>
<img src="/images/request_logs.png" alt="HTTPS request logs table in the Pangolin dashboard" centered/>
<img src="/images/request_logs.png" alt="HTTPS request logs table in the Pangolin dashboard"/>
</Frame>
## HTTPS Request Log Fields
@@ -18,7 +18,7 @@ Open **Organization > Logs & Analytics > Streaming** to add destinations and mon
You choose which categories each destination receives. Only log types enabled for your organization can be streamed.
<Frame>
<img src="/images/streaming-log-types.png" alt="Log type selection for a streaming destination" centered />
<img src="/images/streaming-log-types.png" alt="Log type selection for a streaming destination" />
</Frame>
## Destination types
@@ -26,7 +26,7 @@ You choose which categories each destination receives. Only log types enabled fo
Each destination type has its own configuration and payload behavior. Select **Add destination** and pick a delivery method.
<Frame>
<img src="/images/streaming-add-destination.png" alt="Add destination dialog in the Pangolin dashboard" centered />
<img src="/images/streaming-add-destination.png" alt="Add destination dialog in the Pangolin dashboard" />
</Frame>
<CardGroup cols={2}>
@@ -3,8 +3,6 @@ title: "HTTP webhook"
description: "Forward audit logs to any HTTP endpoint with optional custom body templates"
---
HTTP destinations POST your organization’s audit logs to a URL you control. Use them for generic webhooks, Splunk HEC, Elastic or OpenSearch ingest, Grafana Loki push endpoints, or any receiver that accepts JSON over HTTP.
<Note>
@@ -34,7 +32,7 @@ On the **Settings** tab, set a display name, the endpoint URL, and authenticatio
| Custom header | A single header name and value (for example an API key header) |
<Frame>
<img src="/images/streaming-http-settings.png" alt="HTTP destination settings with URL and authentication options" centered />
<img src="/images/streaming-http-settings.png" alt="HTTP destination settings with URL and authentication options" />
</Frame>
All delivery uses **POST**. Requests time out after 30 seconds.
@@ -44,7 +42,7 @@ All delivery uses **POST**. Requests time out after 30 seconds.
On the **Headers** tab, add optional static headers sent with every request, for example a vendor-specific API key or a non-default `Content-Type`. When you do not override it, Pangolin sends `Content-Type: application/json` (or `application/x-ndjson` when using the NDJSON payload format).
<Frame>
<img src="/images/streaming-http-headers.png" alt="Headers tab for adding static HTTP headers" centered />
<img src="/images/streaming-http-headers.png" alt="Headers tab for adding static HTTP headers" />
</Frame>
## Default payload (template off)
@@ -81,7 +79,7 @@ Some columns are stored as JSON strings in the database (`headers`, `query`, and
On the **Body** tab, enable **Custom body template** and provide a JSON template string. Pangolin performs simple placeholder substitution, **not** a full templating language like Handlebars.
<Frame>
<img src="/images/streaming-http-body.png" alt="Body tab with custom body template editor" centered />
<img src="/images/streaming-http-body.png" alt="Body tab with custom body template editor" />
</Frame>
### Template variables

Some files were not shown because too many files have changed in this diff Show More