diff --git a/.github/workflows/deploy-ingest.yml b/.github/workflows/deploy-ingest.yml new file mode 100644 index 000000000..1e5acd6ac --- /dev/null +++ b/.github/workflows/deploy-ingest.yml @@ -0,0 +1,24 @@ +name: Fly Deploy Ingest +on: + push: + branches: + - main + paths: + - "apps/ingest/**" + - "packages/alert-adapters/**" + - "packages/services/**" + - "packages/db/**" + workflow_dispatch: +jobs: + deploy: + name: Deploy Ingest + runs-on: depot-ubuntu-24.04-4 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: + flyctl deploy --config apps/ingest/fly.toml + --dockerfile apps/ingest/Dockerfile --remote-only --wait-timeout=500 + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7e44e7220..5c1d4aeb6 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,6 +6,7 @@ on: - main paths: - "apps/server/**" + - "apps/ingest/**" - "apps/dashboard/**" - "apps/workflows/**" - "apps/private-location/**" @@ -18,7 +19,7 @@ on: services: description: 'Services to build (comma-separated)' required: false - default: 'server,dashboard,workflows,private-location,status-page,checker,db-migrate' + default: 'server,ingest,dashboard,workflows,private-location,status-page,checker,db-migrate' type: string concurrency: @@ -53,9 +54,9 @@ jobs: # packages/** changes affect all services if echo "$CHANGED" | grep -q '^packages/'; then - SERVICES_LIST=("server" "dashboard" "workflows" "private-location" "status-page" "checker" "db-migrate") + SERVICES_LIST=("server" "ingest" "dashboard" "workflows" "private-location" "status-page" "checker" "db-migrate") else - for svc in server dashboard workflows private-location status-page checker; do + for svc in server ingest dashboard workflows private-location status-page checker; do if echo "$CHANGED" | grep -q "^apps/$svc/"; then SERVICES_LIST+=("$svc") fi @@ -86,6 +87,9 @@ jobs: - service: server context: . dockerfile: apps/server/Dockerfile + - service: ingest + context: . + dockerfile: apps/ingest/Dockerfile - service: dashboard context: . dockerfile: apps/dashboard/Dockerfile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b46e29eb1..7d762e1e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,9 @@ jobs: - name: workflows filter: "--filter=@openstatus/workflows" database: true + - name: ingest + filter: "--filter=@openstatus/ingest" + database: true - name: subscriptions filter: "--filter=@openstatus/subscriptions" database: true @@ -43,6 +46,7 @@ jobs: --filter=!@openstatus/server --filter=!@openstatus/api --filter=!@openstatus/workflows + --filter=!@openstatus/ingest --filter=!@openstatus/subscriptions database: false services: diff --git a/AGENTS.md b/AGENTS.md index 229300d06..ccfefebbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,7 @@ boundaries with external SDKs or at registry-style dispatch. When you need one: - `packages/ui/AGENTS.md` — stock shadcn vs. the published blocks registry - `apps/dashboard/AGENTS.md` — Next.js runtimes, client boundary, UI verification - `apps/server/AGENTS.md` — Hono API, API-key scopes +- `apps/ingest/AGENTS.md` — third-party alert webhooks, the inbox pattern - `apps/status-page/AGENTS.md` — public surfaces and gated content - `apps/workflows/AGENTS.md` — Deno runtime constraints - `apps/checker/AGENTS.md` — Go probing tier diff --git a/DOCKER.md b/DOCKER.md index c29c46a45..46192f9a4 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -55,6 +55,7 @@ docker builder prune | workflows | 3000 | Background jobs | | server | 3001 | API backend (tRPC) | | dashboard | 3002 | Admin interface | +| ingest | 3004 | Third-party alert webhooks | | status-page | 3003 | Public status pages | | private-location | 8081 | Monitoring agent | | libsql | 8080 | Database (HTTP) | @@ -62,6 +63,16 @@ docker builder prune | tinybird-local | 7181 | Analytics | +### Alert ingest and rate limiting + +The `ingest` service receives third-party alert webhooks at +`POST /v1/ingest/`. Per-source rate limiting uses Upstash Redis, which +is **not part of this compose file**: without `UPSTASH_REDIS_REST_URL` and +`UPSTASH_REDIS_REST_TOKEN`, ingest runs normally but applies **no rate limit**. +That is deliberate — a missing cache must not stop alerts from being accepted — +but it means a runaway sender can fill `alert_inbox`. Set those variables if you +expose ingest to the internet. + ## Architecture ``` diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx new file mode 100644 index 000000000..495517560 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { Badge } from "@openstatus/ui/components/ui/badge"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { toast } from "sonner"; + +import { + EmptyStateContainer, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionHeaderRow, + SectionTitle, +} from "@/components/content/section"; +import { useTRPC } from "@/lib/trpc/client"; + +import { PromoteDialog } from "./promote-dialog"; + +const ORIGIN_LABEL = { + monitor: "openstatus monitor", + external: "Ingested alert", + manual: "Opened by hand", +} as const; + +export default function Page() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const params = useParams<{ id: string }>(); + const id = Number(params.id); + + const { data: incident } = useQuery(trpc.incident.get.queryOptions({ id })); + const { data: auditLogs } = useQuery( + trpc.auditLog.list.queryOptions({ + entityType: "incident", + entityId: String(id), + limit: 100, + offset: 0, + }), + ); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: trpc.incident.list.queryKey() }); + queryClient.invalidateQueries({ + queryKey: trpc.incident.get.queryKey({ id }), + }); + queryClient.invalidateQueries({ queryKey: trpc.auditLog.list.queryKey() }); + }; + + const acknowledge = useMutation( + trpc.incident.acknowledge.mutationOptions({ + onSuccess: invalidate, + onError: (error) => toast.error(error.message), + }), + ); + const resolve = useMutation( + trpc.incident.resolve.mutationOptions({ + onSuccess: invalidate, + onError: (error) => toast.error(error.message), + }), + ); + + if (!incident) return null; + + return ( + +
+ + + {incident.title} + + {ORIGIN_LABEL[incident.origin]} · started{" "} + {incident.startedAt.toLocaleString()} + + +
+ {!incident.acknowledgedAt && !incident.resolvedAt ? ( + + ) : null} + {!incident.resolvedAt ? ( + + ) : null} + {!incident.statusReportId ? ( + + ) : null} +
+
+
+ {incident.status} + + {incident.severity} + + {incident.autoResolved ? ( + auto-resolved + ) : null} + {incident.statusReportId ? ( + + published as report #{incident.statusReportId} + + ) : null} +
+ {incident.summary ? ( +

{incident.summary}

+ ) : null} +
+ +
+ + History + + Incidents have no separate timeline — this is the audit log. + + + {!auditLogs || auditLogs.items.length === 0 ? ( + + No recorded changes yet + + ) : ( +
    + {auditLogs.items.map((entry) => ( +
  • + {entry.action} + + {new Date(entry.createdAt).toLocaleString()} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/promote-dialog.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/promote-dialog.tsx new file mode 100644 index 000000000..4fd3d14aa --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/promote-dialog.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Button } from "@openstatus/ui/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@openstatus/ui/components/ui/dialog"; +import { Label } from "@openstatus/ui/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@openstatus/ui/components/ui/select"; +import { Textarea } from "@openstatus/ui/components/ui/textarea"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { useTRPC } from "@/lib/trpc/client"; + +export function PromoteDialog({ + incidentId, + defaultTitle, +}: { + incidentId: number; + defaultTitle: string; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [pageId, setPageId] = useState(""); + const [message, setMessage] = useState(""); + + const { data: pages } = useQuery(trpc.page.list.queryOptions()); + + const promote = useMutation( + trpc.incident.promote.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.incident.list.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.incident.get.queryKey({ id: incidentId }), + }); + toast.success("Published as a status report"); + setOpen(false); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( + + + + + + + Publish “{defaultTitle}” + + Creates a status report on the page you choose, seeded from this + incident. This is the only step that makes it public. + + +
+
+ + +
+
+ +