diff --git a/.claude/commands/implement-todos.md b/.claude/commands/implement-todos.md deleted file mode 100644 index eab720037..000000000 --- a/.claude/commands/implement-todos.md +++ /dev/null @@ -1,60 +0,0 @@ -Implement all spec files in `specs/todos/` sequentially, with commits at each major step. - -IMPORTANT: This is an **automated process**, and as such you should NOT ask for confirmation unless you're doing destructive actions. - -## Process - -1. **Discover**: List all `specs/todos/*.md` files sorted by date (oldest first). If there's none, cancel any active `/loop` (run `/cancel-ralph`) and stop here. -2. **For each spec**, run the loop below -3. **Report**: Once all todos are done, print a summary of what was implemented. Then re-check `specs/todos/` — if new specs appeared, go back to step 1; otherwise, cancel any active `/loop` (run `/cancel-ralph`) and stop. - -## Per-spec loop - -### A. Start - -- Read the spec file fully -- Create a branch: `feat/` (or `fix/` / `chore/` based on spec content) -- Commit: `chore: start implementing ` (empty commit with `--allow-empty`) - -### B. Plan - -- Add an implementation plan as a `## Implementation Plan` section at the bottom of the spec -- Commit: `docs: add implementation plan for ` - -### C. Implement - -For each major step in the plan: - -1. Implement the step -2. Run `make fmt` to format -3. Commit: `feat: ` - -Keep commits granular — one per logical change (new model, new handler, new tests, frontend changes, etc.) - -### D. QA - -1. Run `make build-backend build-client lint-back test` -2. If anything fails, fix it and commit the fix -3. Repeat until all checks pass -4. Commit: `chore: all checks passing for ` - -### E. Archive - -1. Determine the target path: `specs/done/YYYY/MM/` (using the spec's date prefix) -2. Create the directory if needed: `mkdir -p specs/done/YYYY/MM/` -3. `git mv specs/todos/ specs/done/YYYY/MM/` -4. Commit: `docs: archive completed spec ` - -### F. Merge - -1. Switch back to `main` -2. Merge the feature branch: `git merge --no-ff ` -3. Move to the next spec - -## Key Rules - -- **Do not wait for user approval** -- **Never skip QA** — all checks must pass before archiving -- **Commit frequently** — at minimum: start, plan, each major step, QA pass, archive -- If `specs/todos/` is empty, tell the user there's nothing to implement and suggest they move specs there -- Process specs in chronological order (oldest date first) \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index c54e06035..299afb4fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,22 +28,30 @@ If the server is currently running on port 4000, you can just apply code changes wait 5s for it to build and then test your changes. 1. Start infrastructure: `docker-compose up -d` -2. Backend: `make dev-backend` for development server -3. Dashboard: `make dev-dash0` for development server +2. Run everything: `make dev` (backend + dash0 + status0 with hot reload) +3. Or in test mode: `make dev-test` (same but with `SP_RUNMODE=test`) 4. Database changes: Add migrations, run `make migrate` ## Common Makefile Targets - **Build**: - - `make build` - Build complete application (dash + dash0 + backend) + - `make build` - Build complete application (dash + dash0 + status0 + backend) - `make build-dash` - Build dash only (using bun) - `make build-dash0` - Build dash0 status page only (using bun) + - `make build-status0` - Build status0 public status page only (using bun) - `make build-backend` - Build backend only (Go binary) + - `make build-cli` - Build standalone CLI (`sp`) binary + - `make install-cli` - Install standalone CLI to GOPATH - `make docker-build` - Build Docker image - **Development**: - - `make dev-backend` - Start backend development server - - `make dev-dash` - Start dash development server - - `make dev-dash0` - Start dash0 development server - - `make dev-test` - Run backend and dash0 in development test mode + - `make dev` - Run backend, dash0 and status0 in development mode + - `make dev-test` - Run backend, dash0 and status0 in development test mode + - `make dev-backend` - Start backend development server only + - `make dev-dash` - Start dash development server only + - `make dev-dash0` - Start dash0 development server only + - `make dev-status0` - Start status0 development server only +- **Run**: + - `make run` - Build and run the application + - `make run-test` - Build and run the application in test mode - **Testing**: - `make test` - Run backend tests - `make test-dash` - Run dash tests @@ -56,7 +64,7 @@ wait 5s for it to build and then test your changes. - `make deps` - Install all dependencies - `make migrate` - Run database migrations - `make clean` - Remove built binaries and artifacts - - `make run` - Build and run the application + - `make clean-all` - Remove all generated files including node_modules ## Default credentials - User: `admin@solidping.com` @@ -76,21 +84,48 @@ wait 5s for it to build and then test your changes. - Use camelCase consistently for both JSON properties and query parameters (e.g., `checkUid` in JSON and `?checkUid=abc` in URLs) - When using query parameters that can contain multiple values, use them in their singular form, for example `checkUid` and not `checkUids`. If there are multiple values, separate them with `,`. -### API Endpoints -- GET /api/mgmt/version - Get the current version -- GET /api/mgmt/health - Health check endpoint -- POST /api/v1/auth/login - User/password authentication (org optional in body) -- POST /api/v1/auth/logout - Logout current session -- POST /api/v1/auth/refresh - Refresh access token -- GET /api/v1/auth/me - Get current user info -- POST /api/v1/auth/switch-org - Switch organization context -- GET /api/v1/auth/tokens - List all user's tokens across orgs -- DELETE /api/v1/auth/tokens/$tokenUid - Revoke a token -- GET /api/v1/orgs/$org/tokens - List user's tokens for an org -- POST /api/v1/orgs/$org/tokens - Create a Personal Access Token for an org -- GET /api/v1/check-types - List all check types with metadata and server-level activation status -- GET /api/v1/check-types/samples - List sample configs for all check types (optional ?type= filter) -- GET /api/v1/orgs/$org/check-types - List check types resolved for the org (server + org settings) +### API Endpoints (key routes, see `docs/api-specification.md` for full list) +- GET /api/mgmt/version - Version info +- GET /api/mgmt/health - Health check +- POST /api/v1/auth/login - Login (org optional in body) +- POST /api/v1/auth/logout - Logout +- POST /api/v1/auth/refresh - Refresh token +- GET /api/v1/auth/me - Current user info +- PATCH /api/v1/auth/me - Update current user +- POST /api/v1/auth/switch-org - Switch organization +- POST /api/v1/auth/register - Register new user +- POST /api/v1/auth/2fa/setup|confirm|verify|recovery - 2FA management +- GET /api/v1/auth/tokens - List all user tokens +- DELETE /api/v1/auth/tokens/$tokenUid - Revoke token +- GET /api/v1/auth/providers - List OAuth providers +- POST /api/v1/orgs - Create organization +- GET/PATCH /api/v1/orgs/$org/settings - Org settings +- GET/POST /api/v1/orgs/$org/tokens - Org tokens +- GET/POST/DELETE /api/v1/orgs/$org/invitations - Invitations +- CRUD /api/v1/orgs/$org/members - Members +- CRUD /api/v1/orgs/$org/checks - Checks +- POST /api/v1/orgs/$org/checks/validate - Validate check config +- GET/POST /api/v1/orgs/$org/checks/export|import - Import/export +- PUT /api/v1/orgs/$org/checks/$slug - Upsert by slug +- CRUD /api/v1/orgs/$org/check-groups - Check groups +- CRUD /api/v1/orgs/$org/connections - Integration connections +- GET/PUT/POST/DELETE /api/v1/orgs/$org/checks/$check/connections - Check connections +- GET /api/v1/orgs/$org/results - Results +- GET /api/v1/orgs/$org/incidents[/$uid[/events]] - Incidents +- GET /api/v1/orgs/$org/events - Events +- CRUD /api/v1/orgs/$org/status-pages (with nested sections/resources) +- CRUD /api/v1/orgs/$org/maintenance-windows (with nested checks) +- CRUD /api/v1/orgs/$org/jobs - Background jobs +- GET /api/v1/check-types - List check types (public) +- GET /api/v1/check-types/samples - Sample configs (public) +- GET /api/v1/orgs/$org/check-types - Org check types +- GET /api/v1/regions - Global regions (public) +- GET/POST /api/v1/heartbeat/$org/$identifier - Heartbeat ingestion (public) +- GET /api/v1/orgs/$org/checks/$check/badges/$format - Status badges (public) +- GET /api/v1/status-pages/$org[/$slug] - Public status pages +- POST /api/v1/workers/register|heartbeat|claim-jobs|submit-result - Worker API +- POST /api/v1/mcp - MCP endpoint +- GET/PUT/DELETE /api/v1/system/parameters - System params (super admin) ### Errors All errors should return: @@ -105,9 +140,16 @@ All errors should return: - `UNAUTHORIZED` - Authentication required - `FORBIDDEN` - Permission denied - `CONFLICT` - Resource conflict (duplicate, etc.) -- `ORGANIZATION_NOT_FOUND` - Organization does not exist -- `USER_NOT_FOUND` - User does not exist -- `CHECK_NOT_FOUND` - Check does not exist +- `INVALID_CREDENTIALS` - Wrong email/password +- `INVALID_TOKEN` / `NO_TOKEN` - Token issues +- `REGISTRATION_DISABLED` / `EMAIL_NOT_ALLOWED` / `REGISTRATION_EXPIRED` - Registration errors +- `INVITATION_NOT_FOUND` / `INVITATION_EXPIRED` - Invitation errors +- `PASSWORD_RESET_EXPIRED` - Password reset timeout +- `2FA_REQUIRED` / `INVALID_2FA_CODE` / `INVALID_RECOVERY_CODE` - 2FA errors +- `CHECK_HAS_ACTIVE_INCIDENTS` - Cannot delete check with active incidents +- `ORGANIZATION_NOT_FOUND` / `USER_NOT_FOUND` / `CHECK_NOT_FOUND` / `CONNECTION_NOT_FOUND` - Resource not found +- `STATUS_PAGE_NOT_FOUND` / `STATUS_PAGE_SECTION_NOT_FOUND` / `CHECK_GROUP_NOT_FOUND` - Resource not found +- `MAINTENANCE_WINDOW_NOT_FOUND` / `TOKEN_NOT_FOUND` - Resource not found ### API Testing ```bash @@ -132,7 +174,7 @@ make build-cli ``` ## Frontend Error Handling -See `docs/frontend_error_conventions.md` for full details. +See `docs/conventions/frontend-errors.md` for full details. **Key rules:** - **401**: Redirect to login with `?returnTo={currentPath}` to preserve navigation diff --git a/Makefile b/Makefile index a59b05f2d..826bd0faa 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: docker-build build build-backend build-dash build-dash0 build-status0 copy-dash copy-dash0 copy-status0 \ - build-cli install-cli clean clean-all run run-test dev-test dev-dash dev-dash0 dev-status0 dev-backend \ + build-cli install-cli clean clean-all run run-test dev dev-test dev-dash dev-dash0 dev-status0 dev-backend \ test test-dash lint lint-back lint-dash fmt deps migrate help .DEFAULT_GOAL := build @@ -117,6 +117,13 @@ run-test: build ## Build and run the application in test mode @echo "Running application in test mode..." @SP_RUNMODE=test ./$(APP_NAME) serve +dev: kill ## Run backend, dash0 and status0 in development mode + @echo "Running application in development mode..." + @mkdir -p $(LOG_DIR) + @cd $(DASH0_DIR) && bun run dev 2>&1 | tee $(CURDIR)/$(LOG_DIR)/dash0.log & + @cd $(STATUS0_DIR) && bun run dev 2>&1 | tee $(CURDIR)/$(LOG_DIR)/status0.log & + @cd $(BACK_DIR) && SP_REDIRECTS="/dash0:localhost:5174/dash0,/status0:localhost:5175/status0" air 2>&1 | tee $(CURDIR)/$(LOG_DIR)/backend.log + dev-test: kill ## Run backend, dash0 and status0 in development test mode @echo "Running application in development test mode..." @mkdir -p $(LOG_DIR) diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md new file mode 100644 index 000000000..efd52bf73 --- /dev/null +++ b/docs/CLAUDE.md @@ -0,0 +1,29 @@ +# docs/ — Documentation Directory + +Claude has full authority to organize this directory. It may rename, move, split, merge, or restructure any file or subdirectory here as it sees fit to keep the documentation clear, discoverable, and maintainable. + +## Organization Rules + +- **README.md** is the index — it must list every document with a one-line description +- Use subdirectories to group related documents (conventions/, testing/, etc.) +- Use lowercase kebab-case for all file and directory names +- Keep filenames short and descriptive — drop redundant suffixes like `_conventions` +- Competitive analysis lives in `competitors/` +- JSON configs and manifests live near their related docs +- When reorganizing, update all cross-references in CLAUDE.md and specs/ + +## Current Structure + +``` +docs/ + README.md # Full index + CLAUDE.md # This file + architecture.md # System architecture overview + api-specification.md # REST API specification + database-model.md # Database schema & tables + conventions/ # All project conventions + testing/ # Test infrastructure & fixtures + slack/ # Slack app manifests + research/ # Tool evaluations & comparisons + competitors/ # Competitor analysis +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..e2976937a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,49 @@ +# SolidPing Documentation + +## Architecture & Design + +- [architecture.md](architecture.md) — System architecture: handler-service pattern, multi-tenancy, distributed checks, data model, scalability +- [api-specification.md](api-specification.md) — Complete REST API specification: auth, orgs, users, workers, checks, results, config +- [database-model.md](database-model.md) — Database schema with all 28 tables, columns, foreign keys, and design patterns + +## Conventions + +Project-wide standards and naming rules. + +- [conventions/database.md](conventions/database.md) — Table naming, soft deletes, audit trails +- [conventions/checker-config.md](conventions/checker-config.md) — Checker configuration for all protocol types (HTTP, TCP, DNS, SMTP, etc.) +- [conventions/checker-metrics.md](conventions/checker-metrics.md) — Metrics compaction suffixes (_min, _max, _avg, _pct, etc.) +- [conventions/regions.md](conventions/regions.md) — Region naming (`$continent-$region-$city`) and wildcard matching +- [conventions/state-entries.md](conventions/state-entries.md) — State entries table for Slack thread metadata +- [conventions/frontend-urls.md](conventions/frontend-urls.md) — Dashboard URL routing (`/dash/orgs/{orgSlug}/...`) +- [conventions/frontend-errors.md](conventions/frontend-errors.md) — Frontend error handling by HTTP status code + +## Testing + +- [testing/e2e-ci.md](testing/e2e-ci.md) — E2E test infrastructure: CI environment, Playwright config, local execution +- [testing/http-test-checks.md](testing/http-test-checks.md) — Fake API test checks: 5 predefined scenarios (stable, flaky, unstable, slow, 503) + +## Integrations + +- [slack/manifest-dev.json](slack/manifest-dev.json) — Slack app manifest for development +- [slack/manifest-prod.json](slack/manifest-prod.json) — Slack app manifest for production + +## Research + +- [research/screenshot-tools.md](research/screenshot-tools.md) — Go screenshot tools comparison (chromedp, Rod, gowitness, gochro) — Rod recommended + +## Competitors + +Market analysis of uptime monitoring services. + +- [competitors/comparison.md](competitors/comparison.md) — Comparison matrix and pricing across 6 competitors +- [competitors/criteria.md](competitors/criteria.md) — Evaluation framework: pricing, features, protocols, deployment, support +- [competitors/full-list.md](competitors/full-list.md) — Comprehensive directory of all monitoring services +- [competitors/betterstack.md](competitors/betterstack.md) — BetterStack Uptime analysis +- [competitors/checkly.md](competitors/checkly.md) — Checkly analysis (monitoring-as-code, Playwright) +- [competitors/gatus.md](competitors/gatus.md) — Gatus analysis (config-as-code, self-hosted) +- [competitors/healthchecks-io.md](competitors/healthchecks-io.md) — Healthchecks.io analysis (passive/heartbeat monitoring) +- [competitors/pingdom.md](competitors/pingdom.md) — Pingdom analysis (RUM, page speed) +- [competitors/statuscake.md](competitors/statuscake.md) — StatusCake analysis (43 probe locations) +- [competitors/uptime-kuma.md](competitors/uptime-kuma.md) — Uptime Kuma analysis (self-hosted, Vue.js) +- [competitors/uptimerobot.md](competitors/uptimerobot.md) — UptimeRobot analysis (free tier, protocol support) diff --git a/docs/api-specification.md b/docs/api-specification.md new file mode 100644 index 000000000..58436ab83 --- /dev/null +++ b/docs/api-specification.md @@ -0,0 +1,614 @@ +# API Specification + +All API routes are prefixed with `/api/v1` unless otherwise noted. Organization-scoped routes use `:org` to refer to the organization slug. + +## Conventions + +- **Pagination**: Cursor-based. Use `cursor` and `size` query parameters. Responses include `hasMore` and `cursor` for the next page. +- **Filtering**: Multi-value filters use comma-separated values in singular form (e.g., `?checkUid=a,b`). +- **Search**: Use `q` for free-text search. +- **Optional includes**: Use `with` to request related data (e.g., `?with=last_result,check`). +- **JSON conventions**: camelCase for all JSON properties and query parameters. +- **List responses**: Always wrapped in `{ "data": [...] }`, never bare arrays. +- **Updates**: Use `PATCH` for partial updates. +- **Errors**: See [Error Responses](#error-responses) at the bottom. + +--- + +## Management + +### GET /api/mgmt/health +Health check. Auth: public + +### GET /api/mgmt/version +Returns server version, build hash, and build date. Auth: public + +--- + +## Authentication (Public) + +### POST /api/v1/auth/login +Email/password login. Returns access token, refresh token, and user info. Body accepts optional `org` field. + +### POST /api/v1/auth/refresh +Refresh an expired access token using a refresh token. + +### POST /api/v1/auth/register +Register a new user account. Sends a confirmation email. + +### POST /api/v1/auth/confirm-registration +Confirm a registration via email token. Returns access token. + +### POST /api/v1/auth/request-password-reset +Request a password reset email. + +### POST /api/v1/auth/reset-password +Reset password using a reset token. + +### GET /api/v1/auth/invite/:token +Get invitation details by token (used to pre-fill the accept-invite form). + +### POST /api/v1/auth/accept-invite +Accept an organization invitation. Creates the user if needed and returns access token. + +### POST /api/v1/auth/2fa/verify +Verify a 2FA code during login (when login returns a 2FA challenge). + +### POST /api/v1/auth/2fa/recovery +Use a recovery code to bypass 2FA during login. + +### GET /api/v1/auth/providers +List enabled authentication providers (password, OAuth providers). Auth: public + +--- + +## Authentication (Authenticated) + +### POST /api/v1/auth/logout +Logout and invalidate the current session. Auth: required + +### POST /api/v1/auth/switch-org +Switch the user's active organization context. Auth: required + +### GET /api/v1/auth/me +Get the current authenticated user's profile. Auth: required + +### PATCH /api/v1/auth/me +Update the current user's profile (name, password, etc.). Auth: required + +### GET /api/v1/auth/tokens +List all personal access tokens for the current user across all organizations. Auth: required + +### DELETE /api/v1/auth/tokens/:tokenUid +Revoke a personal access token. Auth: required + +### POST /api/v1/auth/2fa/setup +Begin 2FA setup. Returns a TOTP secret and QR code URI. Auth: required + +### POST /api/v1/auth/2fa/confirm +Confirm 2FA setup by verifying a TOTP code. Auth: required + +### DELETE /api/v1/auth/2fa +Disable 2FA for the current user. Auth: required + +--- + +## OAuth Providers (Conditional) + +Each provider is only registered if its `ClientID` is configured. All are public. + +### GET /api/v1/auth/slack/login +### GET /api/v1/auth/slack/callback + +### GET /api/v1/auth/google/login +### GET /api/v1/auth/google/callback + +### GET /api/v1/auth/github/login +### GET /api/v1/auth/github/callback + +### GET /api/v1/auth/microsoft/login +### GET /api/v1/auth/microsoft/callback + +### GET /api/v1/auth/gitlab/login +### GET /api/v1/auth/gitlab/callback + +### GET /api/v1/auth/discord/login +### GET /api/v1/auth/discord/callback + +--- + +## Organizations + +### POST /api/v1/orgs +Create a new organization. Auth: required + +### GET /api/v1/orgs/:org/settings +Get organization settings. Auth: required + +### PATCH /api/v1/orgs/:org/settings +Update organization settings. Auth: required (admin) + +--- + +## Organization Tokens + +### GET /api/v1/orgs/:org/tokens +List the current user's personal access tokens for this organization. Auth: required + +### POST /api/v1/orgs/:org/tokens +Create a personal access token scoped to this organization. Auth: required + +--- + +## Organization Invitations + +### GET /api/v1/orgs/:org/invitations +List pending invitations. Auth: required (admin) + +### POST /api/v1/orgs/:org/invitations +Create a new invitation (sends email). Auth: required (admin) + +### DELETE /api/v1/orgs/:org/invitations/:uid +Revoke a pending invitation. Auth: required (admin) + +--- + +## Members + +### GET /api/v1/orgs/:org/members +List organization members. Auth: required + +### POST /api/v1/orgs/:org/members +Add a member to the organization. Auth: required (admin) + +### GET /api/v1/orgs/:org/members/:uid +Get a member's details. Auth: required + +### PATCH /api/v1/orgs/:org/members/:uid +Update a member's role. Auth: required (admin) + +### DELETE /api/v1/orgs/:org/members/:uid +Remove a member from the organization. Auth: required (admin) + +--- + +## Checks + +### GET /api/v1/orgs/:org/checks +List monitoring checks. Auth: required + +Query parameters: +- `with` - comma-separated: `last_result`, `last_status_change` +- `labels` - filter by labels, format: `key1:value1,key2:value2` +- `checkGroupUid` - filter by check group UID +- `q` - free-text search +- `internal` - filter by internal flag +- `cursor` - pagination cursor +- `limit` - page size (default 20, max 100) + +### POST /api/v1/orgs/:org/checks +Create a new check. Type can be inferred from the config URL. Name and slug are auto-generated if omitted. Auth: required + +### POST /api/v1/orgs/:org/checks/validate +Validate a check configuration without persisting. Auth: required + +### GET /api/v1/orgs/:org/checks/export +Export all checks as JSON. Auth: required + +### POST /api/v1/orgs/:org/checks/import +Import checks from JSON. Auth: required + +### GET /api/v1/orgs/:org/checks/:checkUid +Get a single check by UID or slug. Auth: required + +Query parameters: +- `with` - comma-separated optional includes (e.g., `last_result`) + +### PUT /api/v1/orgs/:org/checks/:slug +Upsert a check by slug (create if not exists, update if exists). Auth: required + +### PATCH /api/v1/orgs/:org/checks/:checkUid +Update a check. Auth: required + +### DELETE /api/v1/orgs/:org/checks/:checkUid +Delete a check (soft delete). Auth: required + +### GET /api/v1/orgs/:org/checks/:checkUid/events +List events for a specific check. Auth: required + +Query parameters: +- `cursor` - pagination cursor +- `size` - page size (default 20, max 100) + +--- + +## Check Connections + +Manage notification/integration connections attached to individual checks. + +### GET /api/v1/orgs/:org/checks/:check/connections +List all connections for a check. Auth: required + +### PUT /api/v1/orgs/:org/checks/:check/connections +Set (replace) all connections for a check. Auth: required + +### POST /api/v1/orgs/:org/checks/:check/connections/:connection +Add a connection to a check. Auth: required + +### DELETE /api/v1/orgs/:org/checks/:check/connections/:connection +Remove a connection from a check. Auth: required + +### GET /api/v1/orgs/:org/checks/:check/connections/:connection +Get connection-specific settings for a check. Auth: required + +### PATCH /api/v1/orgs/:org/checks/:check/connections/:connection +Update connection-specific settings for a check. Auth: required + +--- + +## Check Types + +### GET /api/v1/check-types +List all check types with metadata and server-level activation status. Auth: public + +### GET /api/v1/check-types/samples +List sample configurations for all check types. Supports `?type=` filter. Auth: public + +### GET /api/v1/orgs/:org/check-types +List check types resolved for the organization (merges server and org settings). Auth: required + +--- + +## Check Groups + +### GET /api/v1/orgs/:org/check-groups +List check groups. Auth: required + +### POST /api/v1/orgs/:org/check-groups +Create a check group. Auth: required + +### GET /api/v1/orgs/:org/check-groups/:uid +Get a check group. Auth: required + +### PATCH /api/v1/orgs/:org/check-groups/:uid +Update a check group. Auth: required + +### DELETE /api/v1/orgs/:org/check-groups/:uid +Delete a check group. Auth: required + +--- + +## Results + +### GET /api/v1/orgs/:org/results +List monitoring results across checks. Auth: required + +Query parameters: +- `checkUid` - comma-separated check UIDs or slugs +- `checkType` - comma-separated check types +- `status` - comma-separated: `up`, `down`, `unknown` +- `region` - comma-separated regions +- `periodType` - comma-separated period types +- `periodStartAfter` - RFC3339 timestamp +- `periodEndBefore` - RFC3339 timestamp +- `with` - comma-separated optional fields +- `cursor` - pagination cursor +- `size` - page size (default 100, max 1000) + +--- + +## Incidents + +### GET /api/v1/orgs/:org/incidents +List incidents. Auth: required + +Query parameters: +- `checkUid` - comma-separated check UIDs +- `state` - comma-separated states (e.g., `open`, `resolved`) +- `since` - RFC3339 timestamp +- `until` - RFC3339 timestamp +- `with` - comma-separated: `check` +- `cursor` - pagination cursor +- `size` - page size (default 20, max 100) + +### GET /api/v1/orgs/:org/incidents/:uid +Get a single incident. Auth: required + +Query parameters: +- `with` - comma-separated: `check` + +### GET /api/v1/orgs/:org/incidents/:uid/events +List events for a specific incident. Auth: required + +Query parameters: +- `cursor` - pagination cursor +- `size` - page size (default 20, max 100) + +--- + +## Events + +### GET /api/v1/orgs/:org/events +List events across the organization. Auth: required + +Query parameters: +- `eventType` - comma-separated event types +- `checkUid` - filter by check UID +- `incidentUid` - filter by incident UID +- `cursor` - pagination cursor +- `size` - page size (default 20, max 100) + +--- + +## Regions + +### GET /api/v1/regions +List all available global regions. Auth: public + +### GET /api/v1/orgs/:org/regions +List regions relevant to the organization. Auth: required + +--- + +## Connections (Integrations) + +Manage notification/integration connections (Slack, Discord, webhook, etc.) at the organization level. + +### GET /api/v1/orgs/:org/connections +List all connections. Auth: required + +### POST /api/v1/orgs/:org/connections +Create a new connection. Auth: required + +### GET /api/v1/orgs/:org/connections/:uid +Get a connection. Auth: required + +### PATCH /api/v1/orgs/:org/connections/:uid +Update a connection. Auth: required + +### DELETE /api/v1/orgs/:org/connections/:uid +Delete a connection. Auth: required + +--- + +## Status Pages + +### GET /api/v1/orgs/:org/status-pages +List status pages. Auth: required + +### POST /api/v1/orgs/:org/status-pages +Create a status page. Auth: required + +### GET /api/v1/orgs/:org/status-pages/:statusPageUid +Get a status page. Auth: required + +### PATCH /api/v1/orgs/:org/status-pages/:statusPageUid +Update a status page. Auth: required + +### DELETE /api/v1/orgs/:org/status-pages/:statusPageUid +Delete a status page. Auth: required + +### GET /api/v1/orgs/:org/status-pages/:statusPageUid/sections +List sections of a status page. Auth: required + +### POST /api/v1/orgs/:org/status-pages/:statusPageUid/sections +Create a section. Auth: required + +### GET /api/v1/orgs/:org/status-pages/:statusPageUid/sections/:sectionUid +Get a section. Auth: required + +### PATCH /api/v1/orgs/:org/status-pages/:statusPageUid/sections/:sectionUid +Update a section. Auth: required + +### DELETE /api/v1/orgs/:org/status-pages/:statusPageUid/sections/:sectionUid +Delete a section. Auth: required + +### GET /api/v1/orgs/:org/status-pages/:statusPageUid/sections/:sectionUid/resources +List resources in a section. Auth: required + +### POST /api/v1/orgs/:org/status-pages/:statusPageUid/sections/:sectionUid/resources +Add a resource to a section. Auth: required + +### PATCH /api/v1/orgs/:org/status-pages/:statusPageUid/sections/:sectionUid/resources/:resourceUid +Update a resource. Auth: required + +### DELETE /api/v1/orgs/:org/status-pages/:statusPageUid/sections/:sectionUid/resources/:resourceUid +Remove a resource. Auth: required + +### Public Status Page Views + +### GET /api/v1/status-pages/:org +View the default status page for an organization. Auth: public + +### GET /api/v1/status-pages/:org/:slug +View a specific status page by slug. Auth: public + +--- + +## Maintenance Windows + +### GET /api/v1/orgs/:org/maintenance-windows +List maintenance windows. Auth: required + +### POST /api/v1/orgs/:org/maintenance-windows +Create a maintenance window. Auth: required + +### GET /api/v1/orgs/:org/maintenance-windows/:uid +Get a maintenance window. Auth: required + +### PATCH /api/v1/orgs/:org/maintenance-windows/:uid +Update a maintenance window. Auth: required + +### DELETE /api/v1/orgs/:org/maintenance-windows/:uid +Delete a maintenance window. Auth: required + +### GET /api/v1/orgs/:org/maintenance-windows/:uid/checks +List checks associated with a maintenance window. Auth: required + +### PUT /api/v1/orgs/:org/maintenance-windows/:uid/checks +Set (replace) the checks associated with a maintenance window. Auth: required + +--- + +## Badges + +### GET /api/v1/orgs/:org/checks/:check/badges/:format +Get a status badge for a check (e.g., SVG). Auth: public + +--- + +## Heartbeat + +Token-based authentication via the URL identifier. Used for cron job and heartbeat monitoring. + +### POST /api/v1/heartbeat/:org/:identifier +Send a heartbeat ping. Auth: public (token in URL) + +### GET /api/v1/heartbeat/:org/:identifier +Send a heartbeat ping (GET variant for simple HTTP clients). Auth: public (token in URL) + +--- + +## Workers API + +Used by distributed check workers. Authentication is via worker registration token. + +### POST /api/v1/workers/register +Register a new worker. Auth: worker token + +### POST /api/v1/workers/heartbeat +Send a worker heartbeat. Auth: worker token + +### POST /api/v1/workers/claim-jobs +Claim pending check jobs for execution. Auth: worker token + +### POST /api/v1/workers/submit-result +Submit a check execution result. Auth: worker token + +--- + +## Jobs + +Job management for background tasks. Routes are registered without authentication middleware at the router level (auth may be checked in handlers). + +### GET /api/v1/orgs/:org/jobs +List jobs. Auth: required + +### POST /api/v1/orgs/:org/jobs +Create a job. Auth: required + +### GET /api/v1/orgs/:org/jobs/:uid +Get a job. Auth: required + +### DELETE /api/v1/orgs/:org/jobs/:uid +Cancel a job. Auth: required + +--- + +## Slack Integration + +Inbound endpoints for Slack app integration. + +### GET /api/v1/integrations/slack/oauth +Slack OAuth callback handler. Auth: public (Slack flow) + +### POST /api/v1/integrations/slack/events +Slack Events API webhook. Auth: Slack signature verification + +### POST /api/v1/integrations/slack/command +Slack slash command handler. Auth: Slack signature verification + +### POST /api/v1/integrations/slack/interaction +Slack interactive component handler. Auth: Slack signature verification + +--- + +## MCP (Model Context Protocol) + +### POST /api/v1/mcp +MCP endpoint for AI tool integrations. Auth: required (PAT token, org derived from token) + +--- + +## System (Super Admin) + +### GET /api/v1/system/parameters +List all system parameters. Auth: super-admin + +### GET /api/v1/system/parameters/:key +Get a system parameter by key. Auth: super-admin + +### PUT /api/v1/system/parameters/:key +Set a system parameter. Auth: super-admin + +### DELETE /api/v1/system/parameters/:key +Delete a system parameter. Auth: super-admin + +### POST /api/v1/system/test-email +Send a test email to verify email configuration. Auth: super-admin + +--- + +## Test Endpoints (Development Only) + +These endpoints are always available: + +### POST /api/v1/test/jobs +Create a test email job. Auth: public (dev only) + +### GET /api/v1/fake +Fake API endpoint for testing. Auth: public (dev only) + +These endpoints are only available when `SP_RUNMODE=test`: + +### GET /api/v1/test/state-entries +List internal state entries. Auth: public (test mode only) + +### POST /api/v1/test/checks/bulk +Bulk-create checks for testing. Auth: public (test mode only) + +### DELETE /api/v1/test/checks/bulk +Bulk-delete checks for testing. Auth: public (test mode only) + +### POST /api/v1/test/generate-data +Generate synthetic monitoring data. Auth: public (test mode only) + +### DELETE /api/v1/test/checks/all +Delete all checks. Auth: public (test mode only) + +--- + +## Other Endpoints + +### GET /openapi.yaml +OpenAPI schema definition. Auth: public + +### GET /docs +Swagger/OpenAPI documentation UI. Auth: public + +### GET /metrics +Prometheus metrics (only when `prometheus.enabled` is set). Auth: public + +--- + +## Error Responses + +All errors return JSON with: +```json +{ + "title": "Human-readable description", + "code": "MACHINE_READABLE_CODE", + "detail": "Detailed explanation" +} +``` + +Standard error codes: +- `INTERNAL_ERROR` - Unexpected server error +- `VALIDATION_ERROR` - Input validation failed +- `NOT_FOUND` - Resource not found +- `UNAUTHORIZED` - Authentication required +- `FORBIDDEN` - Permission denied +- `CONFLICT` - Resource conflict (duplicate, etc.) +- `ORGANIZATION_NOT_FOUND` - Organization does not exist +- `USER_NOT_FOUND` - User does not exist +- `CHECK_NOT_FOUND` - Check does not exist diff --git a/docs/api_specification.md b/docs/api_specification.md deleted file mode 100644 index d0d1304cb..000000000 --- a/docs/api_specification.md +++ /dev/null @@ -1,837 +0,0 @@ -# Complete API Specification - -## Base URL Pattern -All APIs follow the pattern: `/api/v1/orgs/{org}/*` where `{org}` maps to `organizations.code` - -## Authentication APIs - -### POST /api/v1/orgs/{org}/auth/login -Login with email/password -```json -Request: -{ - "email": "user@example.com", - "password": "password123" -} - -Response: -{ - "token": "jwt-token", - "user": { - "uid": "uuid", - "email": "user@example.com", - "role": "admin" - } -} -``` - -### POST /api/v1/orgs/{org}/auth/logout -Logout current session -```json -Response: 204 No Content -``` - -### POST /api/v1/orgs/{org}/auth/tokens -Create API token -```json -{ - "name": "My API Token", - "expires_at": "2024-12-31T23:59:59Z" // optional -} ---- -Response: -{ - "token": "pat_xyz123", - "name": "My API Token", - "created_at": "2024-01-01T00:00:00Z" -} -``` - -### GET /api/v1/orgs/{org}/auth/tokens -List user's tokens -```json -Response: -{ - "data": [ - { - "uid": "uuid", - "name": "My API Token", - "created_at": "2024-01-01T00:00:00Z", - "last_used_at": "2024-01-15T10:30:00Z" - } - ] -} -``` - -### DELETE /api/v1/orgs/{org}/auth/tokens/{token_uid} -Revoke API token -```json -Response: 204 No Content -``` - -## Organization Management APIs - -### GET /api/v1/orgs/{org} -Get organization details -```json -Response: -{ - "uid": "uuid", - "code": "my-org", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" -} -``` - -### PUT /api/v1/orgs/{org} -Update organization (admin only) -```json -Request: -{ - "code": "new-org-code" // optional, must be unique -} - -Response: -{ - "uid": "uuid", - "code": "new-org-code", - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-15T12:00:00Z" -} -``` - -### DELETE /api/v1/orgs/{org} -Soft delete organization (admin only) -```json -Response: 204 No Content -``` - -## User Management APIs - -### GET /api/v1/orgs/{org}/users -List organization users -```json -Response: -{ - "data":[{ - "uid": "uuid", - "email": "user@example.com", - "role": "admin", - "auth_provider": { - "uid": "uuid", - "type": "email", - "code": "email-auth" - }, - "created_at": "2024-01-01T00:00:00Z" - }] -} -``` - -### POST /api/v1/orgs/{org}/users -Create new user (admin only) -```json -Request: -{ - "email": "newuser@example.com", - "role": "user", - "password": "temp-password", // optional, for password auth - "auth_provider_uid": "uuid" // optional, for OAuth -} - -Response: -{ - "uid": "uuid", - "email": "newuser@example.com", - "role": "user", - "created_at": "2024-01-15T12:00:00Z" -} -``` - -### GET /api/v1/orgs/{org}/users/{user_uid} -Get user details -```json -Response: -{ - "uid": "uuid", - "email": "user@example.com", - "role": "admin", - "auth_provider": { - "uid": "uuid", - "type": "google", - "code": "google-sso" - }, - "created_at": "2024-01-01T00:00:00Z", - "updated_at": "2024-01-01T00:00:00Z" -} -``` - -### PUT /api/v1/orgs/{org}/users/{user_uid} -Update user -```json -Request: -{ - "email": "updated@example.com", // optional - "role": "viewer", // optional, admin only - "password": "new-password" // optional -} - -Response: -{ - "uid": "uuid", - "email": "updated@example.com", - "role": "viewer", - "updated_at": "2024-01-15T12:00:00Z" -} -``` - -### DELETE /api/v1/orgs/{org}/users/{user_uid} -Soft delete user (admin only) -```json -Response: 204 No Content -``` - -## Auth Provider APIs - -### GET /api/v1/orgs/{org}/auth-providers -List authentication providers -```json -Response: -{ - "data": [ - { - "uid": "uuid", - "code": "google-sso", - "type": "google", - "config": { - "client_id": "google-client-id", - "domain": "example.com" - }, - "created_at": "2024-01-01T00:00:00Z" - } - ] -} -``` - -### POST /api/v1/orgs/{org}/auth-providers -Create auth provider (admin only) -```json -Request: -{ - "code": "github-auth", - "type": "github", - "config": { - "client_id": "github-client-id", - "client_secret": "github-client-secret" - } -} - -Response: -{ - "uid": "uuid", - "code": "github-auth", - "type": "github", - "config": { - "client_id": "github-client-id" - }, - "created_at": "2024-01-15T12:00:00Z" -} -``` - -### PUT /api/v1/orgs/{org}/auth-providers/{provider_uid} -Update auth provider (admin only) -```json -Request: -{ - "config": { - "client_id": "updated-client-id" - } -} - -Response: -{ - "uid": "uuid", - "code": "github-auth", - "type": "github", - "config": { - "client_id": "updated-client-id" - }, - "updated_at": "2024-01-15T12:00:00Z" -} -``` - -### DELETE /api/v1/orgs/{org}/auth-providers/{provider_uid} -Soft delete auth provider (admin only) -```json -Response: 204 No Content -``` - -## Worker Management APIs - -### GET /api/v1/orgs/{org}/workers -List workers -```json -Response: -{ - "data": [ - { - "uid": "uuid", - "code": "worker-eu-1", - "name": "Europe Worker", - "context": { - "region": "eu-1", - "datacenter": "amsterdam" - }, - "last_active_at": "2024-01-15T12:00:00Z", - "status": "active", // derived from last_active_at - "created_at": "2024-01-01T00:00:00Z" - } - ] -} -``` - -### POST /api/v1/orgs/{org}/workers -Register new worker (admin only) -```json -Request: -{ - "code": "worker-us-1", - "name": "US East Worker", - "context": { - "region": "us-east-1", - "datacenter": "virginia" - } -} - -Response: -{ - "uid": "uuid", - "code": "worker-us-1", - "name": "US East Worker", - "context": { - "region": "us-east-1", - "datacenter": "virginia" - }, - "created_at": "2024-01-15T12:00:00Z" -} -``` - -### GET /api/v1/orgs/{org}/workers/{worker_uid} -Get worker details -```json -Response: -{ - "uid": "uuid", - "code": "worker-eu-1", - "name": "Europe Worker", - "context": { - "region": "eu-1" - }, - "last_active_at": "2024-01-15T12:00:00Z", - "status": "active", - "active_jobs": 5, - "total_jobs_executed": 1250, - "created_at": "2024-01-01T00:00:00Z" -} -``` - -## Check Management APIs - -### GET /api/v1/orgs/{org}/checks -List monitoring checks -```json -Query parameters: -- enabled: boolean -- type: string -- limit: number (default: 50) -- offset: number (default: 0) - -Response: -{ - "data": [ - { - "uid": "uuid", - "name": "Website Health Check", - "type": "http", - "config": { - "url": "https://example.com", - "method": "GET", - "timeout": 30 - }, - "enabled": true, - "period": "00:01:00", // 1 minute - "status": { - "last_result": "up", - "last_checked": "2024-01-15T11:59:00Z", - "uptime_24h": 99.5 - }, - "created_at": "2024-01-01T00:00:00Z" - } - ] -} -``` - -### POST /api/v1/orgs/{org}/checks -Create monitoring check -```json -Request: -{ - "name": "API Health Check", - "type": "http", - "config": { - "url": "https://api.example.com/health", - "method": "GET", - "expected_status": 200, - "timeout": 10 - }, - "enabled": true, - "period": "00:05:00" // 5 minutes -} - -Response: -{ - "uid": "uuid", - "name": "API Health Check", - "type": "http", - "config": { - "url": "https://api.example.com/health", - "method": "GET", - "expected_status": 200, - "timeout": 10 - }, - "enabled": true, - "period": "00:05:00", - "created_at": "2024-01-15T12:00:00Z" -} -``` - -### GET /api/v1/orgs/{org}/checks/{check_uid} -Get check details -```json -Response: -{ - "uid": "uuid", - "name": "Website Health Check", - "type": "http", - "config": { - "url": "https://example.com", - "method": "GET", - "timeout": 30 - }, - "enabled": true, - "period": "00:01:00", - "job": { - "uid": "uuid", - "scheduled_at": "2024-01-15T12:01:00Z", - "lease_worker": { - "uid": "uuid", - "name": "Europe Worker" - }, - "lease_expires_at": "2024-01-15T12:02:00Z" - }, - "stats": { - "total_runs": 1440, - "success_rate": 99.2, - "avg_duration_ms": 125, - "uptime_24h": 99.5, - "uptime_7d": 98.8, - "uptime_30d": 99.1 - }, - "created_at": "2024-01-01T00:00:00Z" -} -``` - -### PUT /api/v1/orgs/{org}/checks/{check_uid} -Update monitoring check -```json -Request: -{ - "name": "Updated Check Name", - "config": { - "url": "https://newapi.example.com/health", - "timeout": 15 - }, - "enabled": false, - "period": "00:10:00" -} - -Response: -{ - "uid": "uuid", - "name": "Updated Check Name", - "type": "http", - "config": { - "url": "https://newapi.example.com/health", - "method": "GET", - "timeout": 15 - }, - "enabled": false, - "period": "00:10:00", - "updated_at": "2024-01-15T12:00:00Z" -} -``` - -### DELETE /api/v1/orgs/{org}/checks/{check_uid} -Soft delete check -```json -Response: 204 No Content -``` - -### POST /api/v1/orgs/{org}/checks/{check_uid}/run -Manually trigger check execution -```json -Request: -{ - "worker_uid": "uuid" // optional, specific worker -} - -Response: -{ - "job_uid": "uuid", - "scheduled_at": "2024-01-15T12:00:30Z", - "status": "queued" -} -``` - -## Results & Monitoring APIs - -### GET /api/v1/orgs/{org}/checks/{check_uid}/results -Get check execution results -```json -Query parameters: -- period: string (YYYY, YYYY-MM, YYYY-MM-DD) -- from: ISO datetime -- to: ISO datetime -- status: number (1=up, 2=down, 3=timeout, 4=error) -- limit: number (default: 100) -- offset: number (default: 0) - -Response: -{ - "data": [ - { - "started_at": "2024-01-15T11:59:00Z", - "status": 1, // up - "duration_ms_avg": 125, - "duration_ms_min": 120, - "duration_ms_max": 130, - "availability": 100.0, - "output": { - "response_code": 200, - "response_time": 125, - "response_size": 1024 - }, - "context": { - "worker": "worker-eu-1", - "ip": "203.0.113.1" - } - } - ] -} -``` - -### GET /api/v1/orgs/{org}/checks/{check_uid}/stats -Get aggregated statistics -```json -Query parameters: -- period: string (hour, day, week, month) -- from: ISO datetime -- to: ISO datetime - -Response: -{ - "period": "day", - "from": "2024-01-14T00:00:00Z", - "to": "2024-01-15T00:00:00Z", - "total_checks": 1440, - "uptime_percentage": 99.5, - "avg_response_time": 125, - "min_response_time": 95, - "max_response_time": 250, - "status_breakdown": { - "up": 1433, - "down": 5, - "timeout": 2, - "error": 0 - }, - "hourly_stats": [ - { - "hour": "2024-01-14T00:00:00Z", - "checks": 60, - "uptime": 100.0, - "avg_duration": 120 - } - ] -} -``` - -### GET /api/v1/orgs/{org}/dashboard -Get organization dashboard data -```json -Response: -{ - "summary": { - "total_checks": 15, - "active_checks": 12, - "overall_uptime": 99.2, - "alerts_last_24h": 3 - }, - "recent_incidents": [ - { - "check_uid": "uuid", - "check_name": "API Health", - "status": "down", - "started_at": "2024-01-15T10:30:00Z", - "duration": "00:05:00" - } - ], - "top_checks": [ - { - "check_uid": "uuid", - "name": "Website Health", - "uptime_24h": 99.8, - "avg_response_time": 125, - "status": "up" - } - ], - "worker_status": [ - { - "worker_uid": "uuid", - "name": "Europe Worker", - "status": "active", - "active_jobs": 5 - } - ] -} -``` - -## Configuration Management APIs - -### GET /api/v1/config -Get global configuration -```json -Query parameters: -- category: string (filter by category) - -Response: -{ - "data": [ - { - "uid": "uuid", - "category": "system", - "key": "maintenance-mode", - "value": { - "enabled": false, - "message": "System maintenance in progress" - }, - "created_at": "2024-01-01T00:00:00Z" - }, - { - "uid": "uuid", - "category": "defaults", - "key": "check-timeout", - "value": { - "default_timeout": 30, - "max_timeout": 300 - } - } - ] -} -``` - -### POST /api/v1/config -Set global configuration value (system admin only) -```json -Request: -{ - "category": "system", - "key": "rate-limits", - "value": { - "authenticated": 1000, - "anonymous": 100 - } -} - -Response: -{ - "uid": "uuid", - "category": "system", - "key": "rate-limits", - "value": { - "authenticated": 1000, - "anonymous": 100 - }, - "created_at": "2024-01-15T12:00:00Z" -} -``` - -### PUT /api/v1/config/{config_uid} -Update global configuration value (system admin only) -```json -Request: -{ - "value": { - "authenticated": 2000, - "anonymous": 200 - } -} - -Response: -{ - "uid": "uuid", - "category": "system", - "key": "rate-limits", - "value": { - "authenticated": 2000, - "anonymous": 200 - }, - "updated_at": "2024-01-15T12:00:00Z" -} -``` - -### DELETE /api/v1/config/{config_uid} -Delete global configuration (system admin only) -```json -Response: 204 No Content -``` - -### GET /api/v1/orgs/{org}/config -Get organization configuration -```json -Query parameters: -- category: string (filter by category) - -Response: -{ - "data": [ - { - "uid": "uuid", - "category": "notifications", - "key": "email-alerts", - "value": { - "enabled": true, - "recipients": ["admin@example.com"] - }, - "created_at": "2024-01-01T00:00:00Z" - }, - { - "uid": "uuid", - "category": "integrations", - "key": "slack-webhook", - "value": { - "url": "https://hooks.slack.com/...", - "channel": "#alerts" - } - } - ] -} -``` - -### POST /api/v1/orgs/{org}/config -Set configuration value (admin only) -```json -Request: -{ - "category": "notifications", - "key": "sms-alerts", - "value": { - "enabled": false, - "provider": "twilio" - } -} - -Response: -{ - "uid": "uuid", - "category": "notifications", - "key": "sms-alerts", - "value": { - "enabled": false, - "provider": "twilio" - }, - "created_at": "2024-01-15T12:00:00Z" -} -``` - -### PUT /api/v1/orgs/{org}/config/{config_uid} -Update configuration value (admin only) -```json -Request: -{ - "value": { - "enabled": true, - "provider": "aws-sns" - } -} - -Response: -{ - "uid": "uuid", - "category": "notifications", - "key": "sms-alerts", - "value": { - "enabled": true, - "provider": "aws-sns" - }, - "updated_at": "2024-01-15T12:00:00Z" -} -``` - -### DELETE /api/v1/orgs/{org}/config/{key} -Delete configuration (admin only) -```json -Response: 204 No Content -``` - -## System Management APIs (existing) - -### GET /api/v1/mgmt/version -Get system version -```json -Response: -{ - "version": "1.0.0", - "build": "abc123", - "build_date": "2024-01-15T12:00:00Z" -} -``` - -### GET /v1/api/mgmt/health -System health check -```json -Response: -{ - "status": "healthy", - "database": "connected", - "workers": 3 -} -``` - -## Error Responses - -All APIs use standard HTTP status codes and return errors in this format: -```json -{ - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid input data", - "details": { - "field": "email", - "reason": "Email format is invalid" - } - } -} -``` - -## Pagination - -List endpoints support pagination: - -Query parameters: -- limit: number (max 100, default 50) -- offset: number (default 0) - -## Rate Limiting - -- Authenticated requests: 1000/hour per user -- Public endpoints: 100/hour per IP -- Returns 429 Too Many Requests with Retry-After header diff --git a/docs/architecture.md b/docs/architecture.md index 5088daa0c..223bf195d 100644 Binary files a/docs/architecture.md and b/docs/architecture.md differ diff --git a/docs/checker_config_conventions.md b/docs/checker_config_conventions.md deleted file mode 100644 index 62d6edaba..000000000 --- a/docs/checker_config_conventions.md +++ /dev/null @@ -1,97 +0,0 @@ - -Standard checker configuration (but also optional): -- `url`: The URL to check -- `timeout`: The timeout duration for the check. - -For `http`: -(Can use `host`) -- `url`: The URL to check (http://, https://) -- `method`: The HTTP method to use (e.g., "GET", "POST"). -- `headers`: The HTTP headers to send with the request. -- `auth`: The authentication configuration. - - `auth.type`: The authentication type (e.g., "basic", "digest"). - - `auth.username`: The username for authentication. - - `auth.password`: The password for authentication. -- `expected_status_codes`: A list of expected status codes (e.g., ["200", "201", "2XX"], "2XX" by default) -- `follow_redirects`: Whether to follow redirects (false by default) -- `protocolVersion`: The protocol version to use (e.g., "1.1", "2", "3") -- `certificate` : Certificate related checks - - `certificate.check`: Whether to check the certificate validity (true by default) - - `certificate.expiration_days` (int) : The number of days before expiration to trigger an alert (none by default) -- `body_expect`: A string to search for in the response body (simple string match). Check passes if string is found. -- `body_reject`: A string to search for in the response body (simple string match). Check fails if string is found. -- `body_pattern`: A regular expression pattern to search for in the response body. Check passes if pattern is found. -- `body_pattern_reject`: A regular expression pattern to search for in the response body. Check fails if pattern is found. -- `headers_pattern`: A map of header names to regular expression patterns. Check passes if all specified headers match their patterns. - -For `tcp`: -- `url`: TCP host to connect to (example: tcp://example.com:80) -- `data_format`: "text", "b64", "hex" -- `data_send`: The data to send to the server. -- `data_expect`: The expected data to receive from the server. -- `secure`: Whether to use TLS/SSL for the connection. - -For `udp`: -- `url`: UDP host to connect to (example: udp://example.com:53) -- `data_format`: "text", "b64", "hex" -- `data_send`: The data to send to the server. -- `data_expect`: The expected data to receive from the server. - -For `ntp`: -- `url`: NTP host to connect to (example: ntp://example.com) -- `version`: The NTP version to use (e.g., 3, 4). - -For `dns`: -- `server`: The server to use for resolution -- `query_type`: The DNS query type (e.g., "A", "AAAA", "MX"). -- `query_name`: The DNS query name. -- `query_class`: The DNS query class (e.g., "IN", "CH", "HS"). - -For `smtp`: -- `url`: SMTP host to connect to (example: smtp://example.com) -- `auth`: The authentication configuration. - - `auth.username`: The username for authentication. - - `auth.password`: The password for authentication. -- `email`: The email configuration. - - `email.from`: The email address to send the email from. - - `email.to`: The email address to send the email to. - - `email.subject`: The email subject. - - `email.body`: The email body. - -For `snmp`: -- `url`: SNMP host to connect to (example: snmp://example.com) -- `version`: The SNMP version to use (e.g., "1", "2c", "3"). -- `community`: The SNMP community string. -- `oid`: The SNMP object identifier. - -For `ftp`: -- `url`: FTP host to connect to (example: ftp(s)://example.com) -- `auth`: The authentication configuration. - - `auth.username`: The username for authentication. - - `auth.password`: The password for authentication. -- `secure`: Whether to use FTPS or FTP -- `directory`: The directory to check. -- `file`: The file to check. - -For `ssh`: -- `url`: SSH host to connect to (example: ssh://example.com) -- `auth`: The authentication configuration. - - `auth.username`: The username for authentication. - - `auth.password`: The password for authentication. - - `auth.user_private_key`: The private key for authentication. - - `auth.remote_public_key`: The remote public key for authentication. -- `command`: The command to execute on the remote server. - -For `sftp`: -- `url`: SFTP host to connect to (example: sftp://example.com) -- `auth`: The authentication configuration. - - `auth.username`: The username for authentication. - - `auth.password`: The password for authentication. - - `auth.key`: The private key for authentication. -- `directory`: The directory to check. -- `file`: The file to check. - -For `heartbeat` check: -- `token`: The (optional) token to confirm the check is valid. -- `timeout`: The timeout in seconds. -- `grace_period`: The grace period in seconds. diff --git a/docs/other-services/betterstack.md b/docs/competitors/betterstack.md similarity index 100% rename from docs/other-services/betterstack.md rename to docs/competitors/betterstack.md diff --git a/docs/other-services/checkly.md b/docs/competitors/checkly.md similarity index 100% rename from docs/other-services/checkly.md rename to docs/competitors/checkly.md diff --git a/docs/other-services/comparison.md b/docs/competitors/comparison.md similarity index 100% rename from docs/other-services/comparison.md rename to docs/competitors/comparison.md diff --git a/docs/other-services/comparison-criteria.md b/docs/competitors/criteria.md similarity index 100% rename from docs/other-services/comparison-criteria.md rename to docs/competitors/criteria.md diff --git a/docs/other-services/full-list.md b/docs/competitors/full-list.md similarity index 100% rename from docs/other-services/full-list.md rename to docs/competitors/full-list.md diff --git a/docs/other-services/gatus.md b/docs/competitors/gatus.md similarity index 100% rename from docs/other-services/gatus.md rename to docs/competitors/gatus.md diff --git a/docs/other-services/healthchecks-io.md b/docs/competitors/healthchecks-io.md similarity index 100% rename from docs/other-services/healthchecks-io.md rename to docs/competitors/healthchecks-io.md diff --git a/docs/other-services/pingdom.md b/docs/competitors/pingdom.md similarity index 100% rename from docs/other-services/pingdom.md rename to docs/competitors/pingdom.md diff --git a/docs/other-services/statuscake.md b/docs/competitors/statuscake.md similarity index 100% rename from docs/other-services/statuscake.md rename to docs/competitors/statuscake.md diff --git a/docs/other-services/uptime-kuma.md b/docs/competitors/uptime-kuma.md similarity index 100% rename from docs/other-services/uptime-kuma.md rename to docs/competitors/uptime-kuma.md diff --git a/docs/other-services/uptimerobot.md b/docs/competitors/uptimerobot.md similarity index 100% rename from docs/other-services/uptimerobot.md rename to docs/competitors/uptimerobot.md diff --git a/docs/conventions/checker-config.md b/docs/conventions/checker-config.md new file mode 100644 index 000000000..8bc0619c1 --- /dev/null +++ b/docs/conventions/checker-config.md @@ -0,0 +1,481 @@ +# Checker Configuration Reference + +Complete reference for all 30 checker types and their configuration fields. Field names shown are the JSON keys used in the `config` object of a check. + +**Legend**: (R) = required, (O) = optional. Duration fields accept Go duration strings (e.g., `"10s"`, `"1m"`). + +--- + +## Network + +### `http` -- HTTP/HTTPS endpoint monitoring + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `url` | string | R | | URL to check (http:// or https://) | +| `method` | string | O | GET | HTTP method (GET, POST, etc.) | +| `headers` | map[string]string | O | | HTTP headers to send | +| `body` | string | O | | Request body | +| `username` | string | O | | Basic auth username | +| `password` | string | O | | Basic auth password | +| `expected_status` | int | O | | Expected status code (deprecated, use `expected_status_codes`) | +| `expected_status_codes` | string[] | O | ["2XX"] | Expected status code patterns (e.g., `["200", "2XX"]`) | +| `body_expect` | string | O | | String that must be found in response body | +| `body_reject` | string | O | | String that must NOT be found in response body | +| `body_pattern` | string | O | | Regex pattern that must match in response body | +| `body_pattern_reject` | string | O | | Regex pattern that must NOT match in response body | +| `headers_pattern` | map[string]string | O | | Map of header name to regex pattern; all must match | +| `json_path_assertions` | object | O | | AST-based JSONPath assertions (see below) | + +**JSONPath assertion node** (`json_path_assertions`): + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string | Node type: `"assertion"`, `"and"`, or `"or"` | +| `path` | string | JSONPath expression (for `assertion` type) | +| `operator` | string | Comparison operator (for `assertion` type) | +| `value` | string | Expected value (for `assertion` type) | +| `children` | node[] | Child assertion nodes (for `and`/`or` types) | + +--- + +### `tcp` -- TCP port connectivity + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `url` | string | O | | TCP URL (e.g., `tcp://host:port` or `tcps://host:port`) | +| `host` | string | O | | Hostname (legacy, use `url` instead) | +| `port` | int | O | | Port number (legacy, use `url` instead) | +| `timeout` | duration | O | | Connection timeout | +| `send_data` | string | O | | Data to send after connecting | +| `expect_data` | string | O | | Expected data in response | +| `tls` | bool | O | false | Use TLS/SSL | +| `tls_verify` | bool | O | false | Verify TLS certificate | +| `tls_server_name` | string | O | | Override TLS server name (SNI) | + +Either `url` or `host`+`port` is required. + +--- + +### `udp` -- UDP port reachability + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Target hostname | +| `port` | int | R | | Target port | +| `timeout` | duration | O | | Response timeout | +| `send_data` | string | O | | Data to send | +| `expect_data` | string | O | | Expected response data | + +--- + +### `icmp` -- ICMP ping + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `url` | string | O | | ICMP URL (e.g., `ping://host` or `icmp://host`) | +| `host` | string | O | | Target hostname (legacy, use `url` instead) | +| `timeout` | duration | O | | Ping timeout | +| `count` | int | O | | Number of ping packets | +| `interval` | duration | O | | Interval between packets | +| `packet_size` | int | O | | Packet size in bytes | +| `ttl` | int | O | | Time to live | + +Either `url` or `host` is required. Requires raw socket privileges. + +--- + +### `dns` -- DNS resolution + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `url` | string | O | | DNS URL (e.g., `dns://resolver/domain?type=A`) | +| `host` | string | O | | Domain to query (legacy, use `url` instead) | +| `timeout` | duration | O | | Query timeout | +| `nameserver` | string | O | system resolver | DNS server to use | +| `record_type` | string | O | | Record type: A, AAAA, MX, CNAME, TXT, etc. | +| `expected_ips` | string[] | O | | Expected IP addresses in response | +| `expected_values` | string[] | O | | Expected values in response records | + +Either `url` or `host` is required. Default period: 5 minutes. + +--- + +### `websocket` -- WebSocket connectivity + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `url` | string | R | | WebSocket URL (ws:// or wss://) | +| `headers` | map[string]string | O | | HTTP headers for handshake | +| `send` | string | O | | Text message to send after connecting | +| `expect` | string | O | | Regex pattern to match against received message | +| `tls_skip_verify` | bool | O | false | Skip TLS certificate verification | +| `timeout` | duration | O | 10s | Maximum time for entire check (max: 60s) | + +--- + +### `grpc` -- gRPC health check + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Target hostname | +| `port` | int | O | 50051 | Target port. Validation: 1-65535 | +| `tls` | bool | O | false | Use TLS | +| `tlsSkipVerify` | bool | O | false | Skip TLS certificate verification | +| `serviceName` | string | O | | gRPC service name for health check | +| `timeout` | duration | O | 10s | Check timeout (max: 60s) | +| `keyword` | string | O | | Keyword to search for in response | +| `invertKeyword` | bool | O | false | Fail if keyword IS found (instead of not found) | + +--- + +## Security / Certificates + +### `ssl` -- SSL/TLS certificate validation + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Target hostname | +| `port` | int | O | 443 | Target port. Validation: 1-65535 | +| `threshold_days` | int | O | 30 | Days before expiration to mark as down. Must be >= 0 | +| `timeout` | duration | O | 10s | Connection + handshake timeout (max: 60s) | +| `server_name` | string | O | same as host | Override SNI server name | + +Min period: 1 hour. Default period: 6 hours. + +--- + +### `domain` -- Domain name expiration + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `domain` | string | R | | Domain name to check (e.g., `"google.com"`) | +| `threshold_days` | int | O | 30 | Days before expiration to consider failed | + +Min period: 6 hours. Default period: 24 hours. + +--- + +## Email + +### `smtp` -- SMTP server health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | SMTP server hostname | +| `port` | int | O | 25 | Port. Validation: 1-65535 | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `starttls` | bool | O | false | Use STARTTLS. Cannot combine with port 465 (implicit TLS) | +| `tls_verify` | bool | O | false | Verify TLS certificate | +| `tls_server_name` | string | O | | Override TLS server name | +| `ehlo_domain` | string | O | `solidping.local` | EHLO domain string | +| `expect_greeting` | string | O | | Expected greeting string | +| `check_auth` | bool | O | false | Test authentication | +| `username` | string | O | | Auth username | +| `password` | string | O | | Auth password | + +--- + +### `pop3` -- POP3 server health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | POP3 server hostname | +| `port` | int | O | 110 | Port. Validation: 1-65535 | +| `tls` | bool | O | false | Use implicit TLS. Cannot combine with `starttls` | +| `starttls` | bool | O | false | Use STARTTLS. Cannot combine with `tls` | +| `tls_verify` | bool | O | false | Verify TLS certificate | +| `tls_server_name` | string | O | | Override TLS server name | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `expect_greeting` | string | O | | Expected greeting string | +| `username` | string | O | | Auth username | +| `password` | string | O | | Auth password | + +--- + +### `imap` -- IMAP server health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | IMAP server hostname | +| `port` | int | O | 143 | Port. Validation: 1-65535 | +| `tls` | bool | O | false | Use implicit TLS. Cannot combine with `starttls` | +| `starttls` | bool | O | false | Use STARTTLS. Cannot combine with `tls` | +| `tls_verify` | bool | O | false | Verify TLS certificate | +| `tls_server_name` | string | O | | Override TLS server name | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `expect_greeting` | string | O | | Expected greeting string | +| `username` | string | O | | Auth username | +| `password` | string | O | | Auth password | + +--- + +## Database + +### `postgresql` -- PostgreSQL health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Database hostname | +| `port` | int | O | 5432 | Port. Validation: 1-65535 | +| `username` | string | R | | Database username | +| `password` | string | O | | Database password | +| `database` | string | O | `postgres` | Database name | +| `ssl_mode` | string | O | `prefer` | SSL mode: `disable`, `require`, `verify-ca`, `verify-full`, `prefer` | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `query` | string | O | `SELECT 1` | Health check query. Must start with SELECT | + +--- + +### `mysql` -- MySQL/MariaDB health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Database hostname | +| `port` | int | O | 3306 | Port. Validation: 1-65535 | +| `username` | string | R | | Database username | +| `password` | string | O | | Database password | +| `database` | string | O | | Database name | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `query` | string | O | `SELECT 1` | Health check query. Must start with SELECT | + +--- + +### `mongodb` -- MongoDB health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Database hostname | +| `port` | int | O | 27017 | Port. Validation: 1-65535 | +| `username` | string | O | | Auth username | +| `password` | string | O | | Auth password | +| `database` | string | O | | Database name | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | + +--- + +### `mssql` -- Microsoft SQL Server health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Database hostname | +| `port` | int | O | 1433 | Port. Validation: 1-65535 | +| `username` | string | R | | Database username | +| `password` | string | O | | Database password | +| `database` | string | O | `master` | Database name | +| `encrypt` | string | O | `false` | Encryption mode: `true`, `false`, `disable` | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `query` | string | O | `SELECT 1` | Health check query. Must start with SELECT | + +--- + +### `oracle` -- Oracle Database health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Database hostname | +| `port` | int | O | 1521 | Port. Validation: 1-65535 | +| `username` | string | R | | Database username | +| `password` | string | O | | Database password | +| `serviceName` | string | O | `ORCL` | Oracle service name. Cannot combine with `sid` | +| `sid` | string | O | | Oracle SID. Cannot combine with `serviceName` | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `query` | string | O | `SELECT 1 FROM DUAL` | Health check query. Must start with SELECT | + +--- + +### `redis` -- Redis health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Redis hostname | +| `port` | int | O | 6379 | Port. Validation: 1-65535 | +| `password` | string | O | | Auth password | +| `database` | int | O | 0 | Database index. Validation: 0-15 | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | + +--- + +## Message Queue + +### `kafka` -- Kafka cluster health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `brokers` | string[] or string | R | | Broker addresses (`["host:port"]` or `"host:port,host2:port"`). Each must include port | +| `topic` | string | O | | Kafka topic. Required when `produceTest` is true | +| `saslMechanism` | string | O | | SASL auth mechanism: `PLAIN`, `SCRAM-SHA-256`, `SCRAM-SHA-512` | +| `saslUsername` | string | O | | SASL username (required when `saslMechanism` is set) | +| `saslPassword` | string | O | | SASL password (required when `saslMechanism` is set) | +| `tls` | bool | O | false | Use TLS | +| `tlsSkipVerify` | bool | O | false | Skip TLS certificate verification | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `produceTest` | bool | O | false | Test by producing a message (requires `topic`) | + +--- + +### `rabbitmq` -- RabbitMQ health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | RabbitMQ hostname | +| `port` | int | O | 5672 | AMQP port. Validation: 1-65535 | +| `username` | string | R | | Auth username | +| `password` | string | O | | Auth password | +| `vhost` | string | O | `/` | Virtual host | +| `tls` | bool | O | false | Use TLS (amqps://) | +| `mode` | string | O | `amqp` | Check mode: `amqp` or `management` | +| `managementPort` | int | O | 15672 | Management API port (for `management` mode). Validation: 1-65535 | +| `queue` | string | O | | Queue name to verify | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | + +--- + +### `mqtt` -- MQTT broker health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | MQTT broker hostname | +| `port` | int | O | 1883 (8883 with TLS) | Port. Validation: 1-65535 | +| `username` | string | O | | Auth username | +| `password` | string | O | | Auth password | +| `topic` | string | O | `solidping/healthcheck` | Topic for publish test. Must not contain wildcards (`#` or `+`) | +| `tls` | bool | O | false | Use TLS | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | + +--- + +## Remote Access + +### `ssh` -- SSH server health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | SSH server hostname | +| `port` | int | O | 22 | Port. Validation: 1-65535 | +| `timeout` | duration | O | | Connection timeout (max: 60s) | +| `expected_fingerprint` | string | O | | Expected server key fingerprint | +| `username` | string | O | | Auth username. Requires `password` or `private_key` | +| `password` | string | O | | Auth password. Cannot combine with `private_key` | +| `private_key` | string | O | | PEM-encoded private key. Cannot combine with `password` | +| `command` | string | O | | Command to execute (requires `username`) | +| `expected_exit_code` | int | O | 0 | Expected exit code from command | +| `expected_output` | string | O | | String expected in command output (requires `command`) | +| `expected_output_pattern` | string | O | | Regex pattern expected in output (requires `command`) | + +--- + +### `ftp` -- FTP server health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | FTP server hostname | +| `port` | int | O | 21 | Port. Validation: 1-65535 | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `username` | string | O | `anonymous` | Auth username | +| `password` | string | O | | Auth password | +| `tls_mode` | string | O | `none` | TLS mode: `none`, `explicit`, `implicit` | +| `tls_verify` | bool | O | false | Verify TLS certificate | +| `passive_mode` | bool | O | false | Use passive mode | +| `path` | string | O | | Directory or file path to verify | + +--- + +### `sftp` -- SFTP server health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | SFTP server hostname | +| `port` | int | O | 22 | Port. Validation: 1-65535 | +| `timeout` | duration | O | 10s | Connection timeout (max: 60s) | +| `username` | string | R | | Auth username | +| `password` | string | O | | Auth password. Cannot combine with `private_key`. One of `password`/`private_key` required | +| `private_key` | string | O | | PEM-encoded private key. Cannot combine with `password` | +| `path` | string | O | | Directory or file path to verify | + +--- + +## Infrastructure + +### `snmp` -- SNMP device monitoring + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Target hostname | +| `port` | int | O | 161 | Port. Validation: 1-65535 | +| `version` | string | O | `2c` | SNMP version: `1`, `2c`, `3` | +| `community` | string | O | `public` | Community string (v1/v2c) | +| `oid` | string | R | | Object identifier to query | +| `expectedValue` | string | O | | Expected value from OID query | +| `operator` | string | O | `equals` | Comparison operator: `equals`, `contains`, `greater_than`, `less_than`, `not_equals` | +| `username` | string | O | | SNMPv3 username (required for v3) | +| `authProtocol` | string | O | | Auth protocol: `MD5`, `SHA`, `SHA-256`, `SHA-512` | +| `authPassword` | string | O | | Auth password | +| `privProtocol` | string | O | | Privacy protocol: `DES`, `AES`, `AES-192`, `AES-256`. Requires `authProtocol` | +| `privPassword` | string | O | | Privacy password | +| `timeout` | duration | O | 10s | Query timeout (max: 60s) | + +--- + +### `docker` -- Docker container health + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | O | `unix:///var/run/docker.sock` | Docker daemon host (must start with `unix://` or `tcp://`) | +| `containerName` | string | O* | | Container name. *At least one of `containerName`/`containerId` required | +| `containerId` | string | O* | | Container ID. *At least one of `containerName`/`containerId` required | +| `timeout` | duration | O | 10s | Check timeout (max: 60s) | + +Requires Docker socket access. + +--- + +## Specialized + +### `heartbeat` -- Passive heartbeat monitoring + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `token` | string | O | | Token to validate incoming heartbeat pings | + +The heartbeat checker is passive: it waits for external systems to ping it. The check's `period` and `grace_period` on the check object (not in config) determine when the check is considered down. + +--- + +### `js` -- Custom JavaScript scripts + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `script` | string | R | | JavaScript source code. Max: 64KB. Validated for syntax errors | +| `timeout` | duration | O | 30s | Execution timeout (max: 60s) | +| `env` | map[string]string | O | | Environment variables available to the script. Max: 50 entries | + +Requires scripting runtime. Script runs in a sandboxed Goja (Go-based JS) environment. + +--- + +### `browser` -- Headless Chrome monitoring + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `url` | string | R | | Page URL (must start with `http://` or `https://`). `file://`, `data:`, `javascript:` schemes are rejected | +| `waitSelector` | string | O | | CSS selector to wait for before checking | +| `keyword` | string | O | | Keyword to search in page content | +| `invertKeyword` | bool | O | false | Fail if keyword IS found | +| `timeout` | duration | O | 30s | Page load timeout (max: 120s) | + +Requires Chrome/Chromium installed. + +--- + +### `gameserver` -- Game server A2S query + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `host` | string | R | | Game server hostname | +| `port` | int | O | 27015 | Query port. Validation: 1-65535 | +| `timeout` | duration | O | 10s | Query timeout (max: 30s) | +| `minPlayers` | int | O | | Minimum player count to consider healthy. Must be >= 0 | +| `maxPlayers` | int | O | | Maximum player count to consider healthy. Must be >= 0 | + +Uses the Source Engine A2S_INFO query protocol. diff --git a/docs/checker_metrics_conventions.md b/docs/conventions/checker-metrics.md similarity index 100% rename from docs/checker_metrics_conventions.md rename to docs/conventions/checker-metrics.md diff --git a/docs/db_conventions.md b/docs/conventions/database.md similarity index 100% rename from docs/db_conventions.md rename to docs/conventions/database.md diff --git a/docs/frontend_error_conventions.md b/docs/conventions/frontend-errors.md similarity index 100% rename from docs/frontend_error_conventions.md rename to docs/conventions/frontend-errors.md diff --git a/docs/conventions/frontend-urls.md b/docs/conventions/frontend-urls.md new file mode 100644 index 000000000..73cfcdb0f --- /dev/null +++ b/docs/conventions/frontend-urls.md @@ -0,0 +1,115 @@ +# Frontend URL Conventions + +## General Principle +Frontend URLs mirror the API endpoint structure for consistency and predictability. + +## URL Structure + +### Pattern +``` +/orgs/{org}/[resource]/[resourceUid]/[sub-resource] +``` + +### Conventions +- **Organization scoping**: Routes are scoped to organizations using `/orgs/{org}` +- **Parameter naming**: Uses `$org`, `$checkUid`, `$statusPageUid`, `$incidentUid`, `$token` +- **Routing**: TanStack Router with file-based routing (convention over configuration) +- **Route files**: `web/dash0/src/routes/` and `web/status0/src/routes/` + +## Dashboard Routes (dash0) + +### Public Routes (no auth) +| Path | Description | +|------|-------------| +| `/login` | User login | +| `/forgot-password` | Password reset request | +| `/no-org` | No organization error page | +| `/invite/$token` | Accept invitation | +| `/confirm-registration/$token` | Email confirmation | +| `/reset-password/$token` | Password reset with token | + +### Organization Routes +| Path | Description | +|------|-------------| +| `/orgs/$org/` | Organization home/dashboard | +| `/orgs/$org/login` | Organization-level login | +| `/orgs/$org/register` | Organization-level registration | +| `/orgs/$org/badges` | Badge management | +| `/orgs/$org/events` | Event viewer | + +### Check Routes +| Path | Description | +|------|-------------| +| `/orgs/$org/checks/` | Checks list | +| `/orgs/$org/checks/new` | Create new check | +| `/orgs/$org/checks/$checkUid/` | Check details | +| `/orgs/$org/checks/$checkUid/edit` | Edit check | + +### Incident Routes +| Path | Description | +|------|-------------| +| `/orgs/$org/incidents/` | Incidents list | +| `/orgs/$org/incidents/$incidentUid` | Incident details | + +### Account Routes +| Path | Description | +|------|-------------| +| `/orgs/$org/account/` | Account overview | +| `/orgs/$org/account/profile` | User profile settings | +| `/orgs/$org/account/tokens` | API token management | + +### Organization Settings Routes +| Path | Description | +|------|-------------| +| `/orgs/$org/organization/` | Organization overview | +| `/orgs/$org/organization/settings` | Organization settings | +| `/orgs/$org/organization/invitations` | User invitations | + +### Server Settings Routes (super admin) +| Path | Description | +|------|-------------| +| `/orgs/$org/server/` | Server settings overview | +| `/orgs/$org/server/auth` | Authentication settings | +| `/orgs/$org/server/mail` | Email/mail settings | +| `/orgs/$org/server/performance` | Performance settings | +| `/orgs/$org/server/web` | Web monitoring settings | + +### Status Pages Routes +| Path | Description | +|------|-------------| +| `/orgs/$org/status-pages/` | Status pages list | +| `/orgs/$org/status-pages/new` | Create new status page | +| `/orgs/$org/status-pages/$statusPageUid/` | Status page view | +| `/orgs/$org/status-pages/$statusPageUid/edit` | Edit status page | + +### Test Routes (dev/test mode) +| Path | Description | +|------|-------------| +| `/orgs/$org/test/` | Testing overview | +| `/orgs/$org/test/bulk` | Bulk test operations | +| `/orgs/$org/test/generate` | Generate test data | +| `/orgs/$org/test/reset` | Reset test data | +| `/orgs/$org/test/templates` | Test templates | + +## Status Page Routes (status0) + +| Path | Description | +|------|-------------| +| `/` | Main index | +| `/$org` | Organization status page | +| `/$org/$slug` | Specific status page by slug | + +## Frontend-to-API Mapping + +| Frontend URL | API Endpoint | +|-------------|--------------| +| `/orgs/$org/checks` | `GET /api/v1/orgs/:org/checks` | +| `/orgs/$org/checks/$checkUid` | `GET /api/v1/orgs/:org/checks/:checkUid` | +| `/orgs/$org/incidents` | `GET /api/v1/orgs/:org/incidents` | +| `/orgs/$org/incidents/$incidentUid` | `GET /api/v1/orgs/:org/incidents/:uid` | +| `/orgs/$org/status-pages` | `GET /api/v1/orgs/:org/status-pages` | + +**Key differences:** +- Frontend uses `$` prefix for path parameters (TanStack Router convention) +- API uses `:` prefix for path parameters (bunrouter convention) +- Both use UIDs for resource identification diff --git a/docs/region_conventions.md b/docs/conventions/regions.md similarity index 100% rename from docs/region_conventions.md rename to docs/conventions/regions.md diff --git a/docs/state_entries_conventions.md b/docs/conventions/state-entries.md similarity index 100% rename from docs/state_entries_conventions.md rename to docs/conventions/state-entries.md diff --git a/docs/db_model.md b/docs/database-model.md similarity index 67% rename from docs/db_model.md rename to docs/database-model.md index 86e36c9fc..b73b917ff 100644 --- a/docs/db_model.md +++ b/docs/database-model.md @@ -7,8 +7,9 @@ This document describes all database tables, their purposes, and relationships. ``` organizations (root) ├── parameters (org-scoped config) -├── auth_methods (auth config + optional provider identity) +├── organization_providers (external identity providers) ├── workers (distributed workers) +├── check_groups (flat check grouping) ├── checks │ ├── labels (via check_labels M2M) │ ├── check_jobs (1:n per region) @@ -16,8 +17,12 @@ organizations (root) │ ├── incidents (1:many downtime periods) │ └── check_connections (M2M to integrations) ├── integration_connections -│ ├── integration_user_mappings │ └── check_connections (M2M to checks) +├── status_pages +│ └── status_page_sections +│ └── status_page_resources (links to checks) +├── maintenance_windows +│ └── maintenance_window_checks (links to checks or check_groups) ├── jobs (background tasks) ├── events (audit log) ├── state_entries (KV store) @@ -38,6 +43,7 @@ Multi-tenant root entity for isolating monitoring resources. |--------|------|-------------| | uid | uuid PK | Primary key | | slug | text | URL-friendly unique identifier (3-20 chars) | +| name | text | Human-readable display name | | created_at, updated_at, deleted_at | timestamptz | Timestamps with soft delete | **Foreign Keys**: None (root entity) @@ -74,30 +80,33 @@ Global user accounts with authentication. | email_verified_at | timestamptz | Email verification timestamp | | super_admin | boolean | Can access all organizations | | last_active_at | timestamptz | Last login/activity | +| totp_secret | text | Base32-encoded TOTP secret for 2FA (NULL if not configured) | +| totp_enabled | boolean | Whether TOTP two-factor authentication is active | +| totp_recovery_codes | jsonb | JSON array of hashed one-time recovery codes for 2FA bypass | **Foreign Keys**: None (global entity) --- -### auth_methods -Authentication methods available to an organization. Combines auth configuration with optional provider identity (e.g., Slack team ID). +### organization_providers +Maps organizations to external identity providers. One provider identity belongs to exactly one org. | Column | Type | Description | |--------|------|-------------| | uid | uuid PK | Primary key | | organization_uid | uuid | FK to organizations | -| type | text | Auth method: email-password, google, github, gitlab, microsoft, twitter, oauth2, slack, saml, oidc | -| config | jsonb | Method-specific configuration | -| provider_id | text | External provider ID (e.g., Slack Team ID) - nullable | -| provider_name | text | Human-readable provider name (e.g., "My Slack Workspace") - nullable | +| provider_type | text | Provider type: slack, google, github, gitlab, microsoft, saml, oidc | +| provider_id | text | Unique identifier from the provider (e.g., Slack Team ID) | +| provider_name | text | Human-readable provider name (e.g., "Acme Corp Slack Workspace") | +| metadata | jsonb | Provider-specific metadata | **Foreign Keys**: `organization_uid` → organizations(uid) **Indexes**: -- Unique on (organization_uid, type) - one method per type per org -- Unique on (type, provider_id) where provider_id is not null - for provider lookups +- Unique on (provider_type, provider_id) where not deleted - for provider lookups +- Index on organization_uid where not deleted -**Purpose**: Defines what auth methods are available to an org and optionally links to external provider identity. When a user authenticates via Slack OAuth, the `provider_id` (Slack team ID) determines which organization they belong to. +**Purpose**: Defines which external identity provider identities belong to which organization. When a user authenticates via Slack OAuth, the `provider_id` (Slack team ID) determines which organization they belong to. --- @@ -169,12 +178,29 @@ Distributed workers that execute monitoring checks. | slug | text | Unique system identifier | | name | text | Human-readable name | | region | text | Region identifier (e.g., eu-west-1) | +| token | text | Authentication token for edge worker registration (NULL for manually registered) | | last_active_at | timestamptz | Last heartbeat | **Foreign Keys**: None --- +### check_groups +Flat organizational grouping for checks. A check belongs to zero or one group. + +| Column | Type | Description | +|--------|------|-------------| +| uid | uuid PK | Primary key | +| organization_uid | uuid | FK to organizations | +| name | text | Display name for the group | +| slug | text | URL-friendly identifier, unique per organization | +| description | text | Optional description of what this group contains | +| sort_order | smallint | Display order (lower = higher). Default 0 | + +**Foreign Keys**: `organization_uid` → organizations(uid) + +--- + ### checks Monitoring configurations that define what to monitor. @@ -182,22 +208,28 @@ Monitoring configurations that define what to monitor. |--------|------|-------------| | uid | uuid PK | Primary key | | organization_uid | uuid | FK to organizations | +| check_group_uid | uuid | FK to check_groups (NULL means ungrouped) | | name | text | Check name | | slug | text | URL-friendly identifier (per org) | | description | text | Documentation | -| type | text | Check type: http, tcp, icmp, dns, ssl, etc. | +| type | text | Check type: http, tcp, ping, dns, ssl, etc. | | config | jsonb | Check-specific configuration | | regions | text[] | Regions where check runs | | enabled | boolean | Whether active | +| internal | boolean | Internal checks are hidden from public status pages | | period | interval | Check frequency (default: 1 minute) | | incident_threshold | integer | Failures before incident | | escalation_threshold | integer | Failures before escalation | | recovery_threshold | integer | Successes before recovery | -| status | smallint | Current: 0=unknown, 1=up, 2=down, 3=degraded | +| reopen_cooldown_multiplier | integer | Multiplier for adaptive cooldown before reopening (NULL = system default) | +| max_adaptive_increase | integer | Maximum multiplier for adaptive resolution increase (NULL = system default) | +| status | smallint | Current: 0=unknown, 1=up, 2=down, 3=timeout, 4=error | | status_streak | integer | Current status streak count | | status_changed_at | timestamptz | Last status change | -**Foreign Keys**: `organization_uid` → organizations(uid) +**Foreign Keys**: +- `organization_uid` → organizations(uid) +- `check_group_uid` → check_groups(uid) --- @@ -293,8 +325,8 @@ Time-series check execution results (raw and aggregated). | period_end | timestamptz | Period end | | region | text | Execution region | | worker_uid | uuid | FK to workers (raw only) | -| status | smallint | 0=initial, 1=up, 2=down, 3=timeout, 4=error | -| duration | real | Execution duration in seconds | +| status | smallint | 0=initial, 1=up, 2=down, 3=timeout, 4=error, 5=running | +| duration | real | Execution duration in milliseconds | | metrics | jsonb | Numerical metrics | | output | jsonb | Diagnostic output | | last_for_status | boolean | Most recent per check+status | @@ -326,6 +358,8 @@ Tracks periods when a check is down. | acknowledged_at | timestamptz | When acknowledged | | acknowledged_by | uuid | FK to users | | failure_count | integer | Total failures during incident | +| relapse_count | integer | Number of times reopened after brief recoveries | +| last_reopened_at | timestamptz | When last reopened (NULL if never reopened) | | title | text | Auto-generated title | | description | text | Human-readable description | | details | jsonb | Additional data | @@ -391,7 +425,7 @@ Generic table for all integration connections (Slack, webhook, email, etc.). |--------|------|-------------| | uid | uuid PK | Primary key | | organization_uid | uuid | FK to organizations | -| type | varchar | Integration type: slack, webhook, email, betterstack | +| type | varchar | Integration type: slack, discord, webhook, email, googlechat, mattermost, ntfy, opsgenie, pushover, betterstack | | name | varchar | Human-readable name | | enabled | boolean | Whether active | | is_default | boolean | Auto-attach to new checks | @@ -401,45 +435,116 @@ Generic table for all integration connections (Slack, webhook, email, etc.). --- -### integration_user_mappings -Links external users (e.g., Slack users) to SolidPing users. +### check_connections +Junction table linking checks to integration connections for notifications. | Column | Type | Description | |--------|------|-------------| | uid | uuid PK | Primary key | -| organization_uid | uuid | FK to organizations | +| check_uid | uuid | FK to checks | | connection_uid | uuid | FK to integration_connections | -| connection_user_id | text | User ID on remote system | -| properties | jsonb | User properties (email, name, avatar) | -| user_uid | uuid | FK to users (optional) | +| organization_uid | uuid | FK to organizations | +| settings | jsonb | Per-check override settings (e.g., Slack channel override) | **Foreign Keys**: -- `organization_uid` → organizations(uid) +- `check_uid` → checks(uid) - `connection_uid` → integration_connections(uid) -- `user_uid` → users(uid) +- `organization_uid` → organizations(uid) + +--- -**Purpose**: Integration data mapping - "Which external user maps to which SolidPing user for notifications?" +## Status Page Tables -**Note**: This is distinct from `user_providers`: -- `user_providers` = authentication identity (required user link, global) -- `integration_user_mappings` = integration mapping (optional user link, per-connection) +### status_pages +Public-facing status pages displaying service health to end users. + +| Column | Type | Description | +|--------|------|-------------| +| uid | uuid PK | Primary key | +| organization_uid | uuid | FK to organizations | +| name | text | Page title displayed to visitors | +| slug | text | URL-friendly identifier, unique per organization | +| description | text | Subtitle or description shown on the page | +| visibility | text | Access control: public or private | +| is_default | boolean | At most one default page per org | +| enabled | boolean | Whether the page is accessible | +| show_availability | boolean | Whether to display uptime percentage | +| show_response_time | boolean | Whether to display response time charts | +| history_days | integer | Number of days of history to display (default 90) | +| language | varchar(10) | ISO language code (e.g., en, fr). NULL uses system default | + +**Foreign Keys**: `organization_uid` → organizations(uid) --- -### check_connections -Junction table linking checks to integration connections for notifications. +### status_page_sections +Grouping sections within a status page. + +| Column | Type | Description | +|--------|------|-------------| +| uid | uuid PK | Primary key | +| status_page_uid | uuid | FK to status_pages | +| name | text | Section heading displayed on the page | +| slug | text | URL-friendly identifier, unique per status page | +| position | integer | Display order (lower = higher on page) | + +**Foreign Keys**: `status_page_uid` → status_pages(uid) + +--- + +### status_page_resources +Checks displayed within a status page section. | Column | Type | Description | |--------|------|-------------| | uid | uuid PK | Primary key | +| section_uid | uuid | FK to status_page_sections | | check_uid | uuid | FK to checks | -| connection_uid | uuid | FK to integration_connections | +| public_name | text | Override display name (NULL uses check name) | +| explanation | text | Optional description visible on the public page | +| position | integer | Display order within section (lower = higher) | + +**Foreign Keys**: +- `section_uid` → status_page_sections(uid) +- `check_uid` → checks(uid) + +--- + +## Maintenance Tables + +### maintenance_windows +Scheduled maintenance periods that suppress incident alerts for affected checks. + +| Column | Type | Description | +|--------|------|-------------| +| uid | uuid PK | Primary key | | organization_uid | uuid | FK to organizations | +| title | text | Maintenance window title shown in notifications and status pages | +| description | text | Detailed description of the planned maintenance | +| start_at | timestamptz | When the maintenance window begins | +| end_at | timestamptz | When the maintenance window ends (must be after start_at) | +| recurrence | text | Recurrence pattern: none, daily, weekly, monthly | +| recurrence_end | timestamptz | When the recurring schedule stops (NULL = indefinite) | +| created_by | text | Identifier of the user or system that created this window | + +**Foreign Keys**: `organization_uid` → organizations(uid) + +--- + +### maintenance_window_checks +Links maintenance windows to individual checks or check groups. Exactly one of check_uid or check_group_uid must be set. + +| Column | Type | Description | +|--------|------|-------------| +| uid | uuid PK | Primary key | +| maintenance_window_uid | uuid | FK to maintenance_windows | +| check_uid | uuid | FK to checks (NULL if targeting a group) | +| check_group_uid | uuid | FK to check_groups (NULL if targeting an individual check) | **Foreign Keys**: +- `maintenance_window_uid` → maintenance_windows(uid) - `check_uid` → checks(uid) -- `connection_uid` → integration_connections(uid) -- `organization_uid` → organizations(uid) +- `check_group_uid` → check_groups(uid) --- diff --git a/docs/front_url_conventions.md b/docs/front_url_conventions.md deleted file mode 100644 index 6a4dc49c9..000000000 --- a/docs/front_url_conventions.md +++ /dev/null @@ -1,60 +0,0 @@ -# Frontend URL Conventions - -## General Principle -Frontend URLs should mirror the API endpoint structure to maintain consistency and predictability across the application. - -## URL Structure - -### Pattern -``` -/dash/orgs/{orgSlug}/[resource]/[resourceSlug]/[sub-resource] -``` - -### Conventions -- **Prefix**: All authenticated dashboard routes start with `/dashboard` -- **Organization scoping**: Routes are scoped to organizations using `/orgs/{orgSlug}` -- **Parameter naming**: - - Use `{orgSlug}` for organization identifiers (not `{org}` or `{orgUid}`) - - Use `{resourceSlug}` for resource identifiers (e.g., `{checkSlug}`, `{incidentSlug}`) - - Slugs are human-readable identifiers, not UUIDs -- **Hierarchy**: URLs follow the resource hierarchy, matching API structure - -## Examples - -### Organization Routes -- `/dash/orgs/{orgSlug}` - Organization dashboard/overview -- `/dash/orgs/{orgSlug}/settings` - Organization settings - -### Check Routes -- `/dash/orgs/{orgSlug}/checks` - List all checks for the organization -- `/dash/orgs/{orgSlug}/checks/{checkSlug}` - Check details and monitoring data -- `/dash/orgs/{orgSlug}/checks/{checkSlug}/edit` - Edit check configuration -- `/dash/orgs/{orgSlug}/checks/new` - Create new check - -### Incident Routes -- `/dash/orgs/{orgSlug}/incidents` - List all incidents for the organization -- `/dash/orgs/{orgSlug}/incidents/{incidentUid}` - Incident details - -### User/Auth Routes -- `/dash/orgs/{orgSlug}/users` - List organization users -- `/dash/orgs/{orgSlug}/users/{userUid}` - User profile/details -## Relationship to API Endpoints - -Frontend routes mirror API structure but with differences: - -| Frontend URL | API Endpoint | -|-------------|--------------| -| `/dash/orgs/{orgSlug}/checks` | `GET /api/v1/orgs/{org}/checks` | -| `/dash/orgs/{orgSlug}/checks/{checkSlug}` | `GET /api/v1/orgs/{org}/checks/{checkUid}` | -| `/dahs/orgs/{orgSlug}/incidents` | `GET /api/v1/orgs/{org}/incidents` | - -**Key differences:** -- Frontend uses `/dashboard` prefix instead of `/api/v1` -- Frontend uses human-readable slugs (`{orgSlug}`, `{checkSlug}`) -- API uses technical identifiers (`{org}`, `{checkUid}`) - -## Benefits -- **Consistency**: Predictable URL structure across the application -- **Discoverability**: Users and developers can guess URLs -- **SEO-friendly**: Human-readable slugs are better for search engines -- **Maintainability**: Clear mapping between frontend and backend resources diff --git a/docs/go_screenshot_tools_comparison.md b/docs/research/screenshot-tools.md similarity index 100% rename from docs/go_screenshot_tools_comparison.md rename to docs/research/screenshot-tools.md diff --git a/docs/slack_app_manifest_dev.json b/docs/slack/manifest-dev.json similarity index 100% rename from docs/slack_app_manifest_dev.json rename to docs/slack/manifest-dev.json diff --git a/docs/slack_app_manifest_prod.json b/docs/slack/manifest-prod.json similarity index 100% rename from docs/slack_app_manifest_prod.json rename to docs/slack/manifest-prod.json diff --git a/docs/ci_e2e_tests.md b/docs/testing/e2e-ci.md similarity index 100% rename from docs/ci_e2e_tests.md rename to docs/testing/e2e-ci.md diff --git a/docs/test_http_checks.md b/docs/testing/http-test-checks.md similarity index 100% rename from docs/test_http_checks.md rename to docs/testing/http-test-checks.md diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 9621cfa1a..f8599a384 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -139,7 +139,7 @@ The Go backend follows a clean architecture pattern with strict separation of co - `context` (jsonb) - Execution context (e.g., {"region": "eu-1"}) - `period` - Time period grouping (YYYY, YYYY-MM, YYYY-MM-DD) - `started_at` - Execution timestamp -- `status` - Result status: 1=up, 2=down, 3=timeout, 4=error +- `status` - Result status: 1=created, 2=running, 3=up, 4=down, 5=timeout, 6=error - `duration_ms_*` - Response time metrics (avg/min/max) - `availability` - Uptime percentage - `output` (jsonb) - Detailed results and error messages diff --git a/server/internal/checkers/checkerdef/types.go b/server/internal/checkers/checkerdef/types.go index 753407831..aea1ac3df 100644 --- a/server/internal/checkers/checkerdef/types.go +++ b/server/internal/checkers/checkerdef/types.go @@ -5,21 +5,20 @@ import "time" // Status represents the outcome of a check execution. type Status int -// Check status constants. +// Check status constants — values match models.ResultStatus. const ( - StatusInitial Status = 0 // Check status unknown - StatusUp Status = 1 // Check succeeded - StatusDown Status = 2 // Check failed (target unreachable or unhealthy) - StatusTimeout Status = 3 // Check timed out - StatusError Status = 4 // Internal error during check execution - StatusRunning Status = 5 // Check process started but not yet completed + StatusRunning Status = 2 // Check process started but not yet completed + StatusUp Status = 3 // Check succeeded + StatusDown Status = 4 // Check failed (target unreachable or unhealthy) + StatusTimeout Status = 5 // Check timed out + StatusError Status = 6 // Internal error during check execution ) // String returns the string representation of the status. func (s Status) String() string { switch s { - case StatusInitial: - return "initial" + case StatusRunning: + return "running" case StatusUp: return "up" case StatusDown: @@ -28,8 +27,6 @@ func (s Status) String() string { return "timeout" case StatusError: return "error" - case StatusRunning: - return "running" default: return "unknown" } diff --git a/server/internal/checkworker/worker.go b/server/internal/checkworker/worker.go index 1d6498cca..8ce340d44 100644 --- a/server/internal/checkworker/worker.go +++ b/server/internal/checkworker/worker.go @@ -745,14 +745,26 @@ func (r *CheckWorker) createInternalCheck(ctx context.Context) error { existing, err := r.dbService.GetCheckByUidOrSlug(ctx, r.defaultOrgUID, slug) if err == nil && existing != nil { r.internalCheckUID = existing.UID + + // Fix legacy checks that had type "internal:checkworker" and internal=false + if !existing.Internal || existing.Type != "checkworker" { + internalTrue := true + newType := "checkworker" + _ = r.dbService.UpdateCheck(ctx, existing.UID, &models.CheckUpdate{ + Internal: &internalTrue, + Type: &newType, + }) + } + return nil } // Create new internal check - check := models.NewCheck(r.defaultOrgUID, slug, "internal:checkworker") + check := models.NewCheck(r.defaultOrgUID, slug, "checkworker") name := "Check Worker: " + r.worker.Name check.Name = &name check.Enabled = false // Don't schedule it as a regular check + check.Internal = true if err := r.dbService.CreateCheck(ctx, check); err != nil { return err diff --git a/server/internal/checkworker/worker_test.go b/server/internal/checkworker/worker_test.go index 638cbd49f..6be4ae3de 100644 --- a/server/internal/checkworker/worker_test.go +++ b/server/internal/checkworker/worker_test.go @@ -279,27 +279,27 @@ func TestLastForStatus(t *testing.T) { Scan(ctx) require.NoError(t, err) - // Helper to get all results for this check (excluding initial status=0 result) + // Helper to get all results for this check (excluding initial "created" result) getResults := func() []*models.Result { var results []*models.Result err := dbSvc.DB().NewSelect(). Model(&results). Where("check_uid = ?", check.UID). - Where("status != ?", int(models.ResultStatusInitial)). + Where("status != ?", int(models.ResultStatusCreated)). Order("created_at ASC"). Scan(ctx) require.NoError(t, err) return results } - // Helper to get results with last_for_status = true (excluding initial status=0 result) + // Helper to get results with last_for_status = true (excluding initial "created" result) getLastForStatusResults := func() []*models.Result { var results []*models.Result err := dbSvc.DB().NewSelect(). Model(&results). Where("check_uid = ?", check.UID). Where("last_for_status = ?", true). - Where("status != ?", int(models.ResultStatusInitial)). + Where("status != ?", int(models.ResultStatusCreated)). Order("created_at ASC"). Scan(ctx) require.NoError(t, err) @@ -307,9 +307,9 @@ func TestLastForStatus(t *testing.T) { } t.Run("FirstResultHasLastForStatus", func(t *testing.T) { - // Insert first result with status=1 (up) + // Insert first result with status up resultUID1, _ := uuid.NewV7() - status1 := 1 + status1 := int(models.ResultStatusUp) duration1 := float32(100.0) lastForStatus := true result1 := models.Result{ @@ -360,9 +360,9 @@ func TestLastForStatus(t *testing.T) { t.Run("SecondResultWithSameStatusClearsPrevious", func(t *testing.T) { time.Sleep(10 * time.Millisecond) // Ensure different created_at - // Insert second result with same status=1 (up) + // Insert second result with same status up resultUID2, _ := uuid.NewV7() - status2 := 1 + status2 := int(models.ResultStatusUp) duration2 := float32(150.0) lastForStatus := true result2 := models.Result{ @@ -405,7 +405,7 @@ func TestLastForStatus(t *testing.T) { // Verify only the second result has last_for_status = true lastResults := getLastForStatusResults() - require.Len(t, lastResults, 1, "only one result should have last_for_status=true for status=1") + require.Len(t, lastResults, 1, "only one result should have last_for_status=true for status up") assert.Equal(t, result2.UID, lastResults[0].UID) // Verify first result no longer has last_for_status = true @@ -421,9 +421,9 @@ func TestLastForStatus(t *testing.T) { t.Run("DifferentStatusCanHaveOwnLastForStatus", func(t *testing.T) { time.Sleep(10 * time.Millisecond) // Ensure different created_at - // Insert result with status=2 (down) + // Insert result with status down resultUID3, _ := uuid.NewV7() - status3 := 2 + status3 := int(models.ResultStatusDown) duration3 := float32(0.0) lastForStatus := true result3 := models.Result{ @@ -442,7 +442,7 @@ func TestLastForStatus(t *testing.T) { } err := dbSvc.DB().RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { - // Clear previous last_for_status for status=2 + // Clear previous last_for_status for status down _, err := tx.NewUpdate(). Model((*models.Result)(nil)). Set("last_for_status = NULL"). @@ -464,27 +464,27 @@ func TestLastForStatus(t *testing.T) { results := getResults() require.Len(t, results, 3) - // Verify we have 2 last_for_status results (one for status=1, one for status=2) + // Verify we have 2 last_for_status results (one for status up, one for status down) lastResults := getLastForStatusResults() require.Len(t, lastResults, 2, "should have 2 results with last_for_status=true (one per status)") - // Verify status=1 still has its last result - var status1Results []*models.Result + // Verify status up still has its last result + var statusUpResults []*models.Result err = dbSvc.DB().NewSelect(). - Model(&status1Results). + Model(&statusUpResults). Where("check_uid = ?", check.UID). - Where("status = ?", 1). + Where("status = ?", int(models.ResultStatusUp)). Where("last_for_status = ?", true). Scan(ctx) require.NoError(t, err) - require.Len(t, status1Results, 1) + require.Len(t, statusUpResults, 1) - // Verify status=2 has its last result + // Verify status down has its last result var status2Results []*models.Result err = dbSvc.DB().NewSelect(). Model(&status2Results). Where("check_uid = ?", check.UID). - Where("status = ?", 2). + Where("status = ?", int(models.ResultStatusDown)). Where("last_for_status = ?", true). Scan(ctx) require.NoError(t, err) diff --git a/server/internal/db/models/check.go b/server/internal/db/models/check.go index f4fb220bc..839078dd4 100644 --- a/server/internal/db/models/check.go +++ b/server/internal/db/models/check.go @@ -12,14 +12,14 @@ import ( type CheckStatus int const ( - // CheckStatusUnknown indicates the check status is not yet known. - CheckStatusUnknown CheckStatus = 0 + // CheckStatusCreated indicates the check was just created and hasn't been executed yet. + CheckStatusCreated CheckStatus = 1 // CheckStatusUp indicates the check is healthy. - CheckStatusUp CheckStatus = 1 + CheckStatusUp CheckStatus = 3 // CheckStatusDown indicates the check is failing. - CheckStatusDown CheckStatus = 2 + CheckStatusDown CheckStatus = 4 // CheckStatusDegraded indicates the check is experiencing issues (reserved for future use). - CheckStatusDegraded CheckStatus = 3 + CheckStatusDegraded CheckStatus = 7 ) // Check represents a monitoring configuration. @@ -76,7 +76,7 @@ func NewCheck(orgUID, slug, checkType string) *Check { IncidentThreshold: 3, EscalationThreshold: 10, RecoveryThreshold: 3, - Status: CheckStatusUnknown, + Status: CheckStatusCreated, StatusStreak: 0, CreatedAt: now, UpdatedAt: now, diff --git a/server/internal/db/models/result.go b/server/internal/db/models/result.go index 9bd6f52e6..b24d3f456 100644 --- a/server/internal/db/models/result.go +++ b/server/internal/db/models/result.go @@ -10,25 +10,27 @@ import ( type ResultStatus int const ( - // ResultStatusInitial indicates the check was just created. - ResultStatusInitial ResultStatus = 0 + // ResultStatusCreated indicates the check was just created and hasn't been executed yet. + ResultStatusCreated ResultStatus = 1 + // ResultStatusRunning indicates the check process has started but not yet completed. + ResultStatusRunning ResultStatus = 2 // ResultStatusUp indicates the check passed successfully. - ResultStatusUp ResultStatus = 1 + ResultStatusUp ResultStatus = 3 // ResultStatusDown indicates the check failed. - ResultStatusDown ResultStatus = 2 + ResultStatusDown ResultStatus = 4 // ResultStatusTimeout indicates the check timed out. - ResultStatusTimeout ResultStatus = 3 + ResultStatusTimeout ResultStatus = 5 // ResultStatusError indicates the check encountered an error. - ResultStatusError ResultStatus = 4 - // ResultStatusRunning indicates the check process has started but not yet completed. - ResultStatusRunning ResultStatus = 5 + ResultStatusError ResultStatus = 6 ) // StatusToString converts a ResultStatus integer to its string representation. func StatusToString(status int) string { switch status { - case int(ResultStatusInitial): - return "INITIAL" + case int(ResultStatusCreated): + return "CREATED" + case int(ResultStatusRunning): + return "RUNNING" case int(ResultStatusUp): return "UP" case int(ResultStatusDown): @@ -37,8 +39,6 @@ func StatusToString(status int) string { return "TIMEOUT" case int(ResultStatusError): return "ERROR" - case int(ResultStatusRunning): - return "RUNNING" default: return "UNKNOWN" } diff --git a/server/internal/db/postgres/migrations/002_reorder_result_status.down.sql b/server/internal/db/postgres/migrations/002_reorder_result_status.down.sql new file mode 100644 index 000000000..bc5f2add3 --- /dev/null +++ b/server/internal/db/postgres/migrations/002_reorder_result_status.down.sql @@ -0,0 +1,30 @@ +-- Reverse the result status reordering +-- Current: 1=created, 2=running, 3=up, 4=down, 5=timeout, 6=error +-- Restore: 0=initial, 1=up, 2=down, 3=timeout, 4=error, 5=running + +UPDATE results SET status = CASE status + WHEN 1 THEN 100 -- created → initial (0) + WHEN 2 THEN 105 -- running (2 → 5) + WHEN 3 THEN 101 -- up (3 → 1) + WHEN 4 THEN 102 -- down (4 → 2) + WHEN 5 THEN 103 -- timeout (5 → 3) + WHEN 6 THEN 104 -- error (6 → 4) + ELSE status +END +WHERE status IN (1, 2, 3, 4, 5, 6); + +UPDATE results SET status = status - 100 WHERE status >= 100 AND status <= 105; + +-- Reverse checks.status +-- Current: 1=created, 3=up, 4=down, 7=degraded +-- Restore: 0=unknown, 1=up, 2=down, 3=degraded +UPDATE checks SET status = CASE status + WHEN 1 THEN 100 -- created → unknown (1 → 0) + WHEN 3 THEN 101 -- up (3 → 1) + WHEN 4 THEN 102 -- down (4 → 2) + WHEN 7 THEN 103 -- degraded (7 → 3) + ELSE status +END +WHERE status IN (1, 3, 4, 7); + +UPDATE checks SET status = status - 100 WHERE status >= 100 AND status <= 103; diff --git a/server/internal/db/postgres/migrations/002_reorder_result_status.up.sql b/server/internal/db/postgres/migrations/002_reorder_result_status.up.sql new file mode 100644 index 000000000..4fbc9c653 --- /dev/null +++ b/server/internal/db/postgres/migrations/002_reorder_result_status.up.sql @@ -0,0 +1,34 @@ +-- Reorder result status values to follow lifecycle order: +-- 1=created, 2=running, 3=up, 4=down, 5=timeout, 6=error +-- +-- Old mapping: 0=initial, 1=up, 2=down, 3=timeout, 4=error, 5=running, 6=created +-- New mapping: 1=created, 2=running, 3=up, 4=down, 5=timeout, 6=error + +-- Use offset +100 to avoid collisions during remapping +UPDATE results SET status = CASE status + WHEN 0 THEN 101 -- initial → created (1) + WHEN 1 THEN 103 -- up (1 → 3) + WHEN 2 THEN 104 -- down (2 → 4) + WHEN 3 THEN 105 -- timeout (3 → 5) + WHEN 4 THEN 106 -- error (4 → 6) + WHEN 5 THEN 102 -- running (5 → 2) + WHEN 6 THEN 101 -- created (6 → 1) + ELSE status +END +WHERE status IN (0, 1, 2, 3, 4, 5, 6); + +UPDATE results SET status = status - 100 WHERE status >= 100 AND status <= 106; + +-- Also update checks.status +-- Old: 0=unknown, 1=up, 2=down, 3=degraded +-- New: 1=created, 3=up, 4=down, 7=degraded +UPDATE checks SET status = CASE status + WHEN 0 THEN 101 -- unknown → created (0 → 1) + WHEN 1 THEN 103 -- up (1 → 3) + WHEN 2 THEN 104 -- down (2 → 4) + WHEN 3 THEN 107 -- degraded (3 → 7) + ELSE status +END +WHERE status IN (0, 1, 2, 3); + +UPDATE checks SET status = status - 100 WHERE status >= 101 AND status <= 107; diff --git a/server/internal/db/postgres/postgres.go b/server/internal/db/postgres/postgres.go index a4607490e..464f58ade 100644 --- a/server/internal/db/postgres/postgres.go +++ b/server/internal/db/postgres/postgres.go @@ -908,7 +908,7 @@ func (s *Service) CreateCheck(ctx context.Context, check *models.Check) error { } // Create initial result to mark check creation - initialStatus := int(models.ResultStatusInitial) + initialStatus := int(models.ResultStatusCreated) lastForStatus := true initialResult := models.Result{ UID: uuid.Must(uuid.NewV7()).String(), @@ -1134,6 +1134,10 @@ func (s *Service) UpdateCheck(ctx context.Context, uid string, update *models.Ch query = query.Set("enabled = ?", *update.Enabled) } + if update.Internal != nil { + query = query.Set("internal = ?", *update.Internal) + } + if update.Period != nil { query = query.Set("period = ?", *update.Period) } diff --git a/server/internal/db/sqlite/migrations/002_reorder_result_status.down.sql b/server/internal/db/sqlite/migrations/002_reorder_result_status.down.sql new file mode 100644 index 000000000..bc5f2add3 --- /dev/null +++ b/server/internal/db/sqlite/migrations/002_reorder_result_status.down.sql @@ -0,0 +1,30 @@ +-- Reverse the result status reordering +-- Current: 1=created, 2=running, 3=up, 4=down, 5=timeout, 6=error +-- Restore: 0=initial, 1=up, 2=down, 3=timeout, 4=error, 5=running + +UPDATE results SET status = CASE status + WHEN 1 THEN 100 -- created → initial (0) + WHEN 2 THEN 105 -- running (2 → 5) + WHEN 3 THEN 101 -- up (3 → 1) + WHEN 4 THEN 102 -- down (4 → 2) + WHEN 5 THEN 103 -- timeout (5 → 3) + WHEN 6 THEN 104 -- error (6 → 4) + ELSE status +END +WHERE status IN (1, 2, 3, 4, 5, 6); + +UPDATE results SET status = status - 100 WHERE status >= 100 AND status <= 105; + +-- Reverse checks.status +-- Current: 1=created, 3=up, 4=down, 7=degraded +-- Restore: 0=unknown, 1=up, 2=down, 3=degraded +UPDATE checks SET status = CASE status + WHEN 1 THEN 100 -- created → unknown (1 → 0) + WHEN 3 THEN 101 -- up (3 → 1) + WHEN 4 THEN 102 -- down (4 → 2) + WHEN 7 THEN 103 -- degraded (7 → 3) + ELSE status +END +WHERE status IN (1, 3, 4, 7); + +UPDATE checks SET status = status - 100 WHERE status >= 100 AND status <= 103; diff --git a/server/internal/db/sqlite/migrations/002_reorder_result_status.up.sql b/server/internal/db/sqlite/migrations/002_reorder_result_status.up.sql new file mode 100644 index 000000000..4fbc9c653 --- /dev/null +++ b/server/internal/db/sqlite/migrations/002_reorder_result_status.up.sql @@ -0,0 +1,34 @@ +-- Reorder result status values to follow lifecycle order: +-- 1=created, 2=running, 3=up, 4=down, 5=timeout, 6=error +-- +-- Old mapping: 0=initial, 1=up, 2=down, 3=timeout, 4=error, 5=running, 6=created +-- New mapping: 1=created, 2=running, 3=up, 4=down, 5=timeout, 6=error + +-- Use offset +100 to avoid collisions during remapping +UPDATE results SET status = CASE status + WHEN 0 THEN 101 -- initial → created (1) + WHEN 1 THEN 103 -- up (1 → 3) + WHEN 2 THEN 104 -- down (2 → 4) + WHEN 3 THEN 105 -- timeout (3 → 5) + WHEN 4 THEN 106 -- error (4 → 6) + WHEN 5 THEN 102 -- running (5 → 2) + WHEN 6 THEN 101 -- created (6 → 1) + ELSE status +END +WHERE status IN (0, 1, 2, 3, 4, 5, 6); + +UPDATE results SET status = status - 100 WHERE status >= 100 AND status <= 106; + +-- Also update checks.status +-- Old: 0=unknown, 1=up, 2=down, 3=degraded +-- New: 1=created, 3=up, 4=down, 7=degraded +UPDATE checks SET status = CASE status + WHEN 0 THEN 101 -- unknown → created (0 → 1) + WHEN 1 THEN 103 -- up (1 → 3) + WHEN 2 THEN 104 -- down (2 → 4) + WHEN 3 THEN 107 -- degraded (3 → 7) + ELSE status +END +WHERE status IN (0, 1, 2, 3); + +UPDATE checks SET status = status - 100 WHERE status >= 101 AND status <= 107; diff --git a/server/internal/db/sqlite/sqlite.go b/server/internal/db/sqlite/sqlite.go index 8a254a41d..73b18ba73 100644 --- a/server/internal/db/sqlite/sqlite.go +++ b/server/internal/db/sqlite/sqlite.go @@ -907,7 +907,7 @@ func (s *Service) CreateCheck(ctx context.Context, check *models.Check) error { } // Create initial result to mark check creation - initialStatus := int(models.ResultStatusInitial) + initialStatus := int(models.ResultStatusCreated) lastForStatus := true initialResult := models.Result{ UID: uuid.Must(uuid.NewV7()).String(), @@ -1133,6 +1133,10 @@ func (s *Service) UpdateCheck(ctx context.Context, uid string, update *models.Ch query = query.Set("enabled = ?", *update.Enabled) } + if update.Internal != nil { + query = query.Set("internal = ?", *update.Internal) + } + if update.Period != nil { query = query.Set("period = ?", *update.Period) } diff --git a/server/internal/handlers/badges/service.go b/server/internal/handlers/badges/service.go index 588bc8425..e286c4aea 100644 --- a/server/internal/handlers/badges/service.go +++ b/server/internal/handlers/badges/service.go @@ -166,6 +166,10 @@ func calculateAvailability(results []*models.Result) float64 { for _, result := range results { if result.Status != nil { + status := models.ResultStatus(*result.Status) + if status == models.ResultStatusCreated || status == models.ResultStatusRunning { + continue + } total++ if *result.Status == int(models.ResultStatusUp) { @@ -204,14 +208,22 @@ func formatAvailability(pct float64) string { func (s *Service) calculateUptimeDuration(results []*models.Result) time.Duration { // Iterate from newest to oldest, find first non-up status - for idx, result := range results { + seenCount := 0 + for _, result := range results { + if result.Status != nil { + status := models.ResultStatus(*result.Status) + if status == models.ResultStatusCreated || status == models.ResultStatusRunning { + continue + } + } if result.Status != nil && *result.Status != int(models.ResultStatusUp) { - if idx == 0 { + if seenCount == 0 { return 0 // Currently down } return time.Since(result.PeriodStart) } + seenCount++ } // All results are up if len(results) > 0 { diff --git a/server/internal/handlers/base/base.go b/server/internal/handlers/base/base.go index 37e041d2f..245af3384 100644 --- a/server/internal/handlers/base/base.go +++ b/server/internal/handlers/base/base.go @@ -32,7 +32,6 @@ const ( ErrorCodeNoToken ErrorCode = "NO_TOKEN" ErrorCodeInvalidOrg ErrorCode = "INVALID_ORG" ErrorCodeTokenNotFound ErrorCode = "TOKEN_NOT_FOUND" - ErrorCodeCheckHasActiveIncidents ErrorCode = "CHECK_HAS_ACTIVE_INCIDENTS" ErrorCodeOAuthFailed ErrorCode = "OAUTH_FAILED" ErrorCodeInvalidState ErrorCode = "INVALID_STATE" ErrorCodeEmailNotVerified ErrorCode = "EMAIL_NOT_VERIFIED" diff --git a/server/internal/handlers/checks/handler.go b/server/internal/handlers/checks/handler.go index 890413f72..608539ea6 100644 --- a/server/internal/handlers/checks/handler.go +++ b/server/internal/handlers/checks/handler.go @@ -494,10 +494,6 @@ func (h *Handler) handleDeleteError(writer http.ResponseWriter, err error) error case errors.Is(err, ErrCheckNotFound): return h.WriteErrorErr( writer, http.StatusNotFound, base.ErrorCodeCheckNotFound, "Check not found", err) - case errors.Is(err, ErrCheckHasActiveIncidents): - return h.WriteErrorErr( - writer, http.StatusConflict, base.ErrorCodeCheckHasActiveIncidents, - "Cannot delete check with active incidents", err) default: return h.WriteInternalError(writer, err) } diff --git a/server/internal/handlers/checks/service.go b/server/internal/handlers/checks/service.go index b7c7f8fa0..e73fcd800 100644 --- a/server/internal/handlers/checks/service.go +++ b/server/internal/handlers/checks/service.go @@ -55,8 +55,6 @@ var ( ErrSlugGenerationFailed = errors.New("could not generate unique slug after 99 attempts") // ErrInvalidSlugFormat is returned when a slug has an invalid format (e.g., looks like a UUID). ErrInvalidSlugFormat = errors.New("invalid slug format") - // ErrCheckHasActiveIncidents is returned when trying to delete a check with active incidents. - ErrCheckHasActiveIncidents = errors.New("check has active incidents") // ErrInvalidCursor is returned when the cursor parameter is malformed. ErrInvalidCursor = errors.New("invalid cursor") // ErrUnsupportedExportVersion is returned when the export document has an unsupported version. @@ -898,13 +896,34 @@ func (s *Service) DeleteCheck(ctx context.Context, orgSlug, identifier string) e return ErrCheckNotFound } - // Check for active incidents - count, err := s.db.CountActiveIncidentsByCheckUID(ctx, check.UID) + // Count active incidents for the event payload + activeIncidentCount, err := s.db.CountActiveIncidentsByCheckUID(ctx, check.UID) if err != nil { return fmt.Errorf("failed to check active incidents: %w", err) } - if count > 0 { - return ErrCheckHasActiveIncidents + + // Resolve any active incidents before deleting + if activeIncidentCount > 0 { + incidents, listErr := s.db.ListIncidents(ctx, &models.ListIncidentsFilter{ + OrganizationUID: org.UID, + CheckUIDs: []string{check.UID}, + States: []models.IncidentState{models.IncidentStateActive}, + }) + if listErr != nil { + return fmt.Errorf("failed to list active incidents: %w", listErr) + } + + now := time.Now() + resolvedState := models.IncidentStateResolved + + for _, incident := range incidents { + if updateErr := s.db.UpdateIncident(ctx, incident.UID, &models.IncidentUpdate{ + State: &resolvedState, + ResolvedAt: &now, + }); updateErr != nil { + return fmt.Errorf("failed to resolve incident %s: %w", incident.UID, updateErr) + } + } } // Delete all check jobs for this check @@ -913,13 +932,32 @@ func (s *Service) DeleteCheck(ctx context.Context, orgSlug, identifier string) e return fmt.Errorf("failed to list check jobs: %w", err) } for _, job := range existingJobs { - if err := s.db.DeleteCheckJob(ctx, job.UID); err != nil { - return fmt.Errorf("failed to delete check job: %w", err) + if delErr := s.db.DeleteCheckJob(ctx, job.UID); delErr != nil { + return fmt.Errorf("failed to delete check job: %w", delErr) } } // Delete check - return s.db.DeleteCheck(ctx, check.UID) + if err := s.db.DeleteCheck(ctx, check.UID); err != nil { + return fmt.Errorf("failed to delete check: %w", err) + } + + // Emit check.deleted event + event := models.NewEvent(org.UID, models.EventTypeCheckDeleted, models.ActorTypeUser) + event.CheckUID = &check.UID + event.Payload = models.JSONMap{ + "check_uid": check.UID, + "check_slug": check.Slug, + "check_name": check.Name, + "check_type": check.Type, + "active_incidents_count": activeIncidentCount, + } + + if err := s.db.CreateEvent(ctx, event); err != nil { + slog.WarnContext(ctx, "failed to emit check.deleted event", "error", err) + } + + return nil } // ensureUniqueSlug ensures the slug is unique within the organization. @@ -1158,6 +1196,8 @@ func (s *Service) convertResultToLastResultResponse(result *models.Result) *Last statusStr = "timeout" case int(models.ResultStatusError): statusStr = "error" + case int(models.ResultStatusCreated): + statusStr = "created" } } diff --git a/server/internal/handlers/results/service.go b/server/internal/handlers/results/service.go index a18181746..40c062ca8 100644 --- a/server/internal/handlers/results/service.go +++ b/server/internal/handlers/results/service.go @@ -184,10 +184,10 @@ func (s *Service) resolveCheckIdentifiers(ctx context.Context, orgUID string, id func (s *Service) mapStatusStringsToInts(statuses []string) []int { statusMap := map[string][]int{ - "up": {1}, - "down": {2, 3, 4}, // down, timeout, error - "unknown": {0}, - "running": {5}, + "created": {int(models.ResultStatusCreated)}, + "running": {int(models.ResultStatusRunning)}, + "up": {int(models.ResultStatusUp)}, + "down": {int(models.ResultStatusDown), int(models.ResultStatusTimeout), int(models.ResultStatusError)}, } var result []int @@ -322,12 +322,14 @@ func (s *Service) statusIntToString(status *int) string { } switch *status { - case 1: + case int(models.ResultStatusCreated): + return "created" + case int(models.ResultStatusRunning): + return "running" + case int(models.ResultStatusUp): return "up" - case 2, 3, 4: + case int(models.ResultStatusDown), int(models.ResultStatusTimeout), int(models.ResultStatusError): return "down" - case 5: - return "running" default: return "unknown" } diff --git a/server/internal/handlers/results/service_test.go b/server/internal/handlers/results/service_test.go index e4170dad3..fce62a5d5 100644 --- a/server/internal/handlers/results/service_test.go +++ b/server/internal/handlers/results/service_test.go @@ -7,6 +7,8 @@ import ( "time" "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/db/models" ) func TestEncodeCursor(t *testing.T) { @@ -139,27 +141,30 @@ func TestMapStatusStringsToInts(t *testing.T) { { name: "up status", statusStrs: []string{"up"}, - expectedInts: []int{1}, + expectedInts: []int{int(models.ResultStatusUp)}, }, { name: "down status maps to multiple", statusStrs: []string{"down"}, - expectedInts: []int{2, 3, 4}, + expectedInts: []int{int(models.ResultStatusDown), int(models.ResultStatusTimeout), int(models.ResultStatusError)}, }, { - name: "unknown status", + name: "unknown status not mapped", statusStrs: []string{"unknown"}, - expectedInts: []int{0}, + expectedInts: []int{}, }, { name: "multiple statuses", - statusStrs: []string{"up", "unknown"}, - expectedInts: []int{1, 0}, + statusStrs: []string{"up", "created"}, + expectedInts: []int{int(models.ResultStatusUp), int(models.ResultStatusCreated)}, }, { - name: "case insensitive", - statusStrs: []string{"UP", "Down", "UnKnOwN"}, - expectedInts: []int{1, 2, 3, 4, 0}, + name: "case insensitive", + statusStrs: []string{"UP", "Down"}, + expectedInts: []int{ + int(models.ResultStatusUp), int(models.ResultStatusDown), + int(models.ResultStatusTimeout), int(models.ResultStatusError), + }, }, { name: "invalid status", @@ -167,9 +172,12 @@ func TestMapStatusStringsToInts(t *testing.T) { expectedInts: []int{}, }, { - name: "mixed valid and invalid", - statusStrs: []string{"up", "invalid", "down"}, - expectedInts: []int{1, 2, 3, 4}, + name: "mixed valid and invalid", + statusStrs: []string{"up", "invalid", "down"}, + expectedInts: []int{ + int(models.ResultStatusUp), int(models.ResultStatusDown), + int(models.ResultStatusTimeout), int(models.ResultStatusError), + }, }, { name: "empty slice", @@ -179,12 +187,16 @@ func TestMapStatusStringsToInts(t *testing.T) { { name: "running status", statusStrs: []string{"running"}, - expectedInts: []int{5}, + expectedInts: []int{int(models.ResultStatusRunning)}, }, { - name: "all status types", - statusStrs: []string{"up", "down", "unknown", "running"}, - expectedInts: []int{1, 2, 3, 4, 0, 5}, + name: "all status types", + statusStrs: []string{"up", "down", "running", "created"}, + expectedInts: []int{ + int(models.ResultStatusUp), int(models.ResultStatusDown), + int(models.ResultStatusTimeout), int(models.ResultStatusError), + int(models.ResultStatusRunning), int(models.ResultStatusCreated), + }, }, } @@ -231,11 +243,12 @@ func TestStatusIntToString(t *testing.T) { }{ {"nil status", nil, "unknown"}, {"status 0", intPtr(0), "unknown"}, - {"status 1 (up)", intPtr(1), "up"}, - {"status 2 (down)", intPtr(2), "down"}, - {"status 3 (timeout)", intPtr(3), "down"}, - {"status 4 (error)", intPtr(4), "down"}, - {"status 5 (running)", intPtr(5), "running"}, + {"status 1 (created)", intPtr(int(models.ResultStatusCreated)), "created"}, + {"status 2 (running)", intPtr(int(models.ResultStatusRunning)), "running"}, + {"status 3 (up)", intPtr(int(models.ResultStatusUp)), "up"}, + {"status 4 (down)", intPtr(int(models.ResultStatusDown)), "down"}, + {"status 5 (timeout)", intPtr(int(models.ResultStatusTimeout)), "down"}, + {"status 6 (error)", intPtr(int(models.ResultStatusError)), "down"}, {"invalid status -1", intPtr(-1), "unknown"}, {"invalid status 99", intPtr(99), "unknown"}, } diff --git a/server/internal/handlers/statuspages/service.go b/server/internal/handlers/statuspages/service.go index 7d151cd20..7a57edf37 100644 --- a/server/internal/handlers/statuspages/service.go +++ b/server/internal/handlers/statuspages/service.go @@ -1068,11 +1068,11 @@ func (s *Service) getCheckInfo(ctx context.Context, orgUID, checkUID string) (*R return nil, err } - statusStr := "unknown" + statusStr := "created" switch check.Status { - case models.CheckStatusUnknown: - statusStr = "unknown" + case models.CheckStatusCreated: + statusStr = "created" case models.CheckStatusUp: statusStr = "up" case models.CheckStatusDown: diff --git a/server/internal/integrations/slack/mention_commands.go b/server/internal/integrations/slack/mention_commands.go index c80d297e4..eb285107c 100644 --- a/server/internal/integrations/slack/mention_commands.go +++ b/server/internal/integrations/slack/mention_commands.go @@ -415,9 +415,9 @@ func statusIntToString(status *int) string { } switch *status { - case 1: + case int(models.ResultStatusUp): return statusUp - case 2, 3, 4: + case int(models.ResultStatusDown), int(models.ResultStatusTimeout), int(models.ResultStatusError): return statusDown default: return "unknown" diff --git a/server/internal/jobs/jobtypes/job_aggregation.go b/server/internal/jobs/jobtypes/job_aggregation.go index 494a44fc3..fa92d48a5 100644 --- a/server/internal/jobs/jobtypes/job_aggregation.go +++ b/server/internal/jobs/jobtypes/job_aggregation.go @@ -651,7 +651,7 @@ func processRawResult( ) { // Skip non-data statuses (initial, running) — they are lifecycle markers, not measurements if result.Status != nil && - (*result.Status == int(models.ResultStatusRunning) || *result.Status == int(models.ResultStatusInitial)) { + (*result.Status == int(models.ResultStatusRunning) || *result.Status == int(models.ResultStatusCreated)) { return } diff --git a/server/internal/jobs/jobtypes/job_aggregation_test.go b/server/internal/jobs/jobtypes/job_aggregation_test.go index 43becc0c8..43b70d4b0 100644 --- a/server/internal/jobs/jobtypes/job_aggregation_test.go +++ b/server/internal/jobs/jobtypes/job_aggregation_test.go @@ -365,7 +365,7 @@ func TestAggregateResults_ExcludesNonDataStatuses(t *testing.T) { statusUp := int(models.ResultStatusUp) statusDown := int(models.ResultStatusDown) statusRunning := int(models.ResultStatusRunning) - statusInitial := int(models.ResultStatusInitial) + statusInitial := int(models.ResultStatusCreated) duration := float32(100.0) workerUID := testWorkerUID diff --git a/server/internal/jobs/jobworker/worker.go b/server/internal/jobs/jobworker/worker.go index 79e10aa75..a0f5e3205 100644 --- a/server/internal/jobs/jobworker/worker.go +++ b/server/internal/jobs/jobworker/worker.go @@ -314,14 +314,26 @@ func (w *JobWorker) createInternalCheck(ctx context.Context) error { existing, err := w.dbService.GetCheckByUidOrSlug(ctx, w.defaultOrgUID, slug) if err == nil && existing != nil { w.internalCheckUID = existing.UID + + // Fix legacy checks that had type "internal:jobworker" and internal=false + if !existing.Internal || existing.Type != "jobworker" { + internalTrue := true + newType := "jobworker" + _ = w.dbService.UpdateCheck(ctx, existing.UID, &models.CheckUpdate{ + Internal: &internalTrue, + Type: &newType, + }) + } + return nil } // Create new internal check - check := models.NewCheck(w.defaultOrgUID, slug, "internal:jobworker") + check := models.NewCheck(w.defaultOrgUID, slug, "jobworker") name := "Job Worker: " + w.workerSlug check.Name = &name check.Enabled = false // Don't schedule it as a regular check + check.Internal = true if err := w.dbService.CreateCheck(ctx, check); err != nil { return err diff --git a/server/test/integration/results_test.go b/server/test/integration/results_test.go index acc6a3e4e..d83c91e91 100644 --- a/server/test/integration/results_test.go +++ b/server/test/integration/results_test.go @@ -83,9 +83,9 @@ func setupResultsTestData(ctx context.Context, t *testing.T, ts *TestServer) { usEastRegion := testRegionUSEast1 euWestRegion := "eu-west-1" workerUID := testWorkerUID - status1 := 1 // up - status2 := 2 // down - status3 := 3 // timeout + status1 := int(models.ResultStatusUp) + status2 := int(models.ResultStatusDown) + status3 := int(models.ResultStatusTimeout) duration1 := float32(100.5) duration2 := float32(50.0) duration3 := float32(95.0) diff --git a/specs/backlog/2026-03-30-org-check-rate-limit.md b/specs/backlog/2026-03-30-org-check-rate-limit.md new file mode 100644 index 000000000..ab6a0b291 --- /dev/null +++ b/specs/backlog/2026-03-30-org-check-rate-limit.md @@ -0,0 +1,407 @@ +# Organization Check Rate Limiting + +## Overview + +SolidPing currently has no limit on how many checks an organization can run or how frequently they execute. As the platform grows, some organizations may consume disproportionate worker capacity by creating many high-frequency checks. This spec introduces a **checks-per-minute rate limit** per organization — a quota set exclusively by super admins that caps the total check execution rate for an org. + +When an org's total desired rate exceeds its limit, all check_job periods are **proportionally scaled up** so the total effective rate equals the limit. The relative priority between checks is preserved: a check configured at 30s stays 2× faster than one at 60s, even after scaling. + +**Design decisions:** +- **Proportional fair scaling** — simplest algorithm that preserves relative check priorities; no priority tiers or weighting needed initially +- **Super admin only** — org admins see the effect but cannot change the limit; this is an operational/billing lever +- **No schema migration** — uses the existing `parameters` table for storage +- **Desired vs effective period** — `checks.period` remains the user-configured value; `check_jobs.period` becomes the effective (possibly scaled) value +- **Idempotent rebalancing** — a single `rebalanceOrgCheckJobs` function can be called from any trigger point + +--- + +## Algorithm: Proportional Fair Scaling + +### Definitions + +| Symbol | Meaning | +|--------|---------| +| `N` | Number of enabled checks in the org | +| `P_i` | Desired period of check `i` (as configured by the user) | +| `r_i` | Desired rate of check `i` = `60 / P_i` (checks/minute, where `P_i` is in seconds) | +| `R` | Total desired rate = `Σ r_i` | +| `L` | Org rate limit (checks/minute), from `limits.checks_per_minute` parameter | +| `α` | Scale factor = `R / L` (always ≥ 1 when applied) | + +### Logic + +``` +if L is NULL or L ≤ 0: + → no limit, all jobs use desired periods (α = 1) + +R = Σ (60 / P_i) for all enabled checks + +if R ≤ L: + → within budget, all jobs use desired periods (α = 1) + +if R > L: + α = R / L + for each check i: + effective_period_i = ceil_to_second(P_i × α) +``` + +### Properties + +- **Preserves relative priority**: `r_i' / r_j' = r_i / r_j` — a check that was 2× faster stays 2× faster +- **Periods only increase**: `α ≥ 1` means no check ever runs faster than its desired period +- **Minimum period respected**: since desired periods are already ≥ 5s and scaling only increases them +- **Quantization**: periods are rounded up to the nearest whole second after scaling to avoid sub-second drift; this means actual total rate may be slightly below `L`, which is safe + +### Multi-region interaction + +A check with `N_regions` regions creates `N_regions` jobs, each with period `P × N_regions`, staggered so the effective rate is `1/P`. The rate contribution is always `60/P_desired` regardless of region count. + +When scaling is applied: + +``` +job_period = ceil_to_second(P_desired × α) × N_regions +``` + +The scale factor applies to the base period before region multiplication. + +### Worked example + +Org rate limit: **2 checks/minute** + +| Check | Desired period | Desired rate (checks/min) | +|-------|---------------|--------------------------| +| A | 30s | 2.0 | +| B | 60s | 1.0 | +| C | 120s | 0.5 | + +- Total desired rate `R = 3.5 checks/min` +- Limit `L = 2 checks/min` +- Scale factor `α = 3.5 / 2 = 1.75` + +| Check | Desired period | Scaled period | Effective rate | +|-------|---------------|---------------|----------------| +| A | 30s | ceil(30 × 1.75) = 53s | 1.132 | +| B | 60s | ceil(60 × 1.75) = 105s | 0.571 | +| C | 120s | ceil(120 × 1.75) = 210s | 0.286 | + +- Total effective rate = **1.989 checks/min** (≤ 2.0 ✓, slightly under due to ceiling) +- Relative priority preserved: A is ~2× faster than B, B is ~2× faster than C ✓ + +### Adding a check to a rate-limited org + +Continuing the example, a new check D with period 60s is created: + +- New total desired rate = `3.5 + 1.0 = 4.5` +- New `α = 4.5 / 2 = 2.25` +- All jobs are rebalanced: + +| Check | Desired period | New scaled period | New effective rate | +|-------|---------------|-------------------|-------------------| +| A | 30s | 68s | 0.882 | +| B | 60s | 135s | 0.444 | +| C | 120s | 270s | 0.222 | +| D | 60s | 135s | 0.444 | + +- Total = **1.993 checks/min** ✓ + +### Removing a check relaxes the remaining checks + +If check D is deleted, `α` drops back to 1.75 and all remaining jobs return to the periods in the first table. + +If check C is also deleted, `R = 3.0`, `α = 3.0/2 = 1.5`: + +| Check | Desired period | Scaled period | Effective rate | +|-------|---------------|---------------|----------------| +| A | 30s | 45s | 1.333 | +| B | 60s | 90s | 0.667 | + +- Total = **2.0 checks/min** ✓ + +--- + +## Data Model + +### Storage + +Uses the existing `parameters` table — no migration needed. + +| Field | Value | +|-------|-------| +| `organization_uid` | The org's UID | +| `key` | `limits.checks_per_minute` | +| `value` | `{"value": 2.0}` (JSONMap) | +| `secret` | `false` | + +### Separation of desired vs effective period + +- `checks.period` — the user-configured desired period. Never modified by rate limiting. +- `check_jobs.period` — the effective period, computed as `ceil_to_second(desired × α) × num_regions`. Updated by `rebalanceOrgCheckJobs`. + +--- + +## API + +### Super admin endpoints + +All under `RequireSuperAdmin` middleware. + +#### Set rate limit + +``` +PUT /api/v1/system/orgs/:org/limits/checks-per-minute +``` + +Request: +```json +{"value": 2.0} +``` + +Response `200`: +```json +{ + "value": 2.0, + "currentRate": 3.5, + "scaleFactor": 1.75, + "rateLimited": true +} +``` + +Validation: +- `value` must be a positive number (> 0) +- Setting triggers `rebalanceOrgCheckJobs` + +#### Get rate limit + +``` +GET /api/v1/system/orgs/:org/limits/checks-per-minute +``` + +Response `200` (limit set): +```json +{ + "value": 2.0, + "currentRate": 3.5, + "scaleFactor": 1.75, + "rateLimited": true +} +``` + +Response `200` (no limit): +```json +{ + "value": null, + "currentRate": 3.5, + "scaleFactor": 1.0, + "rateLimited": false +} +``` + +#### Remove rate limit + +``` +DELETE /api/v1/system/orgs/:org/limits/checks-per-minute +``` + +Response `204`. Triggers `rebalanceOrgCheckJobs` to restore all jobs to desired periods. + +### Check response changes + +Add two fields to `CheckResponse`: + +```go +type CheckResponse struct { + // ... existing fields ... + EffectivePeriod *string `json:"effectivePeriod,omitempty"` // only set when different from period + RateLimited bool `json:"rateLimited,omitempty"` // true when org rate limit is active +} +``` + +- `effectivePeriod` is only included when it differs from `period` (i.e., when rate limiting is active) +- `rateLimited` indicates whether the org's rate limit is currently being enforced + +--- + +## Implementation + +### Core function: `rebalanceOrgCheckJobs` + +```go +func (s *Service) rebalanceOrgCheckJobs(ctx context.Context, orgUID string) error { + // 1. Fetch the org rate limit + limit, err := s.getOrgRateLimit(ctx, orgUID) // returns 0 if not set + + // 2. Fetch all enabled checks for the org + checks, err := s.db.ListEnabledChecksByOrg(ctx, orgUID) + + // 3. Compute total desired rate + totalRate := 0.0 + for _, c := range checks { + totalRate += 60.0 / c.Period.Seconds() + } + + // 4. Compute scale factor + alpha := 1.0 + if limit > 0 && totalRate > limit { + alpha = totalRate / limit + } + + // 5. Reconcile all check jobs with the scale factor + for _, c := range checks { + if err := s.reconcileCheckJobsWithScale(ctx, &c, alpha); err != nil { + return err + } + } + return nil +} +``` + +### Refactoring `reconcileCheckJobs` + +The existing `reconcileCheckJobs` becomes a thin wrapper: + +```go +func (s *Service) reconcileCheckJobs(ctx context.Context, check *models.Check) error { + return s.reconcileCheckJobsWithScale(ctx, check, 1.0) +} +``` + +`reconcileCheckJobsWithScale` is the existing `reconcileCheckJobs` logic with one change: `basePeriod = ceilToSecond(check.Period × alpha)` instead of `basePeriod = check.Period`. + +### Period quantization helper + +```go +func ceilToSecond(d time.Duration) time.Duration { + if d%time.Second == 0 { + return d + } + return d.Truncate(time.Second) + time.Second +} +``` + +### Rebalancing triggers + +| Trigger | Location | What to do | +|---------|----------|------------| +| Check created | `Service.CreateCheck` | After DB insert + initial job creation, call `rebalanceOrgCheckJobs` | +| Check updated (period/enabled/regions) | `Service.UpdateCheck` | Replace single-check `reconcileCheckJobs` with `rebalanceOrgCheckJobs` | +| Check deleted | `Service.DeleteCheck` | After deleting jobs + check, call `rebalanceOrgCheckJobs` to relax remaining | +| Rate limit changed | `RateLimitHandler.Set/Delete` | Call `rebalanceOrgCheckJobs` after parameter write | + +### Scale factor for API responses + +To populate `effectivePeriod` and `rateLimited` on check responses without N+1 queries: + +```go +func (s *Service) getOrgScaleFactor(ctx context.Context, orgUID string) (float64, error) { + limit, err := s.getOrgRateLimit(ctx, orgUID) + if err != nil || limit <= 0 { + return 1.0, err + } + checks, err := s.db.ListEnabledChecksByOrg(ctx, orgUID) + if err != nil { + return 1.0, err + } + totalRate := 0.0 + for _, c := range checks { + totalRate += 60.0 / c.Period.Seconds() + } + if totalRate <= limit { + return 1.0, nil + } + return totalRate / limit, nil +} +``` + +Called once per `ListChecks`/`GetCheck` request, then applied to each response item. + +### Route registration + +In `server.go`, after existing system routes: + +```go +orgLimits := systemGroup.NewGroup("/orgs/:org/limits") +orgLimits.PUT("/checks-per-minute", rateLimitHandler.SetChecksPerMinute) +orgLimits.GET("/checks-per-minute", rateLimitHandler.GetChecksPerMinute) +orgLimits.DELETE("/checks-per-minute", rateLimitHandler.DeleteChecksPerMinute) +``` + +### New files + +- `server/internal/handlers/ratelimits/handler.go` — HTTP handler +- `server/internal/handlers/ratelimits/service.go` — business logic (calls parameter DB ops + rebalance) + +### Modified files + +- `server/internal/handlers/checks/service.go` — `rebalanceOrgCheckJobs`, `reconcileCheckJobsWithScale`, `getOrgScaleFactor`, response field population +- `server/internal/app/server.go` — route registration + +--- + +## Edge Cases + +| Scenario | Behavior | +|----------|----------| +| No limit set for org | All checks run at desired periods. `α = 1`. | +| Limit set but total rate is within budget | Same as no limit — no scaling applied. | +| Single check exceeds limit | That one check is slowed down. E.g., check with period=10s (6/min) and limit=2/min → α=3, effective period=30s. | +| All checks disabled | Total rate = 0, no jobs exist, no scaling needed. | +| Limit set to very small value | All periods scale up proportionally. A limit of 0.1/min with a 30s check → α=12, period=360s. | +| Limit removed | `rebalanceOrgCheckJobs` runs with α=1, restoring all desired periods. | +| Concurrent check creation | Two simultaneous creates may compute slightly stale α. Last rebalance wins and is correct. Acceptable for initial implementation. | +| Check with multiple regions | Rate contribution is `60/P_desired` (not per-region). Scale applies before region multiplication. | + +--- + +## Testing + +### Unit tests + +```go +// TestCeilToSecond — exact seconds unchanged, sub-second rounds up +// TestGetOrgScaleFactor — no limit → 1.0; within budget → 1.0; over budget → correct α +// TestRebalanceOrgCheckJobs — 3 checks, limit set, verify all job periods scaled correctly +// TestRebalanceNoLimit — verify jobs use desired periods when no limit +// TestRebalanceWithMultiRegion — verify scale + region multiplication +// TestRebalanceOnCheckCreate — create check in rate-limited org, all jobs rebalanced +// TestRebalanceOnCheckDelete — delete check, remaining jobs relax toward desired +// TestRebalanceOnCheckDisable — disable check, remaining jobs relax +``` + +### Integration tests + +```go +// TestRateLimitCRUD — super admin sets/gets/deletes limit via API +// TestRateLimitForbiddenForOrgAdmin — non-super-admin gets 403 +// TestEffectivePeriodInCheckResponse — create checks, set limit, verify effectivePeriod in list +// TestRebalanceE2E — create 3 checks, set limit below total, verify job periods in DB +``` + +### Manual verification + +```bash +# Login as super admin +TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' \ + -d '{"email":"admin@solidping.com","password":"solidpass"}' \ + 'http://localhost:4000/api/v1/auth/login' | jq -r '.accessToken') + +# Create some checks with short periods +# (use existing check creation API) + +# Set rate limit to 2 checks/minute +curl -s -X PUT -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"value": 2.0}' \ + 'http://localhost:4000/api/v1/system/orgs/default/limits/checks-per-minute' | jq . + +# Verify checks show effective periods +curl -s -H "Authorization: Bearer $TOKEN" \ + 'http://localhost:4000/api/v1/orgs/default/checks' | jq '.data[] | {slug, period, effectivePeriod, rateLimited}' + +# Remove limit and verify periods return to normal +curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \ + 'http://localhost:4000/api/v1/system/orgs/default/limits/checks-per-minute' +``` + +--- + +**Status**: Backlog | **Created**: 2026-03-30 diff --git a/specs/done/2026/02/2026-02-17-test-mode-page.md b/specs/done/2026/02/2026-02-17-test-mode-page.md index 30bb83149..78e9c515b 100644 --- a/specs/done/2026/02/2026-02-17-test-mode-page.md +++ b/specs/done/2026/02/2026-02-17-test-mode-page.md @@ -76,7 +76,7 @@ Each card shows: - A "Create" button that calls `useCreateCheck()` with pre-filled data - After creation, a toast notification with a link to the new check -**Pre-filled check templates** (derived from `docs/test_http_checks.md`): +**Pre-filled check templates** (derived from `docs/testing/http-test-checks.md`): | Template | Name | Period | URL suffix | Description | |----------|------|--------|------------|-------------| diff --git a/specs/ideas/2026-01-05-screenshots.md b/specs/ideas/2026-01-05-screenshots.md index e48760c9d..33d256ad4 100644 --- a/specs/ideas/2026-01-05-screenshots.md +++ b/specs/ideas/2026-01-05-screenshots.md @@ -6,7 +6,7 @@ Take screenshots of websites when HTTP checks fail, similar to BetterStack. ## Technology Choice: Rod -After evaluating Go-based screenshot tools (see [docs/go_screenshot_tools_comparison.md](../../docs/go_screenshot_tools_comparison.md)), **Rod** (`github.com/go-rod/rod`) is the chosen library for the screenshot service: +After evaluating Go-based screenshot tools (see [docs/research/screenshot-tools.md](../../docs/research/screenshot-tools.md)), **Rod** (`github.com/go-rod/rod`) is the chosen library for the screenshot service: - Best concurrency model (multiplexed events, no fixed-buffer bottleneck) - Lower memory per tab (decode-on-demand, no DOM caching) diff --git a/web/dash0/src/api/hooks.ts b/web/dash0/src/api/hooks.ts index 5b258f6b1..f23ce5f31 100644 --- a/web/dash0/src/api/hooks.ts +++ b/web/dash0/src/api/hooks.ts @@ -103,7 +103,7 @@ export interface OrgResult { checkUid?: string; checkName?: string; checkSlug?: string; - status?: "up" | "down" | "unknown"; + status?: "up" | "down" | "unknown" | "created"; durationMs?: number; durationMinMs?: number; durationMaxMs?: number; diff --git a/web/dash0/src/components/checks/response-time-chart.tsx b/web/dash0/src/components/checks/response-time-chart.tsx index fef0a2cfe..e32971b64 100644 --- a/web/dash0/src/components/checks/response-time-chart.tsx +++ b/web/dash0/src/components/checks/response-time-chart.tsx @@ -341,15 +341,16 @@ export function ResponseTimeChart({ const realPoints = chartData.filter((p) => p.durationMs != null); if (realPoints.length < 2) { const color = - realPoints.length === 1 && realPoints[0].status !== "up" + realPoints.length === 1 && realPoints[0].status !== "up" && realPoints[0].status !== "created" && realPoints[0].status !== "running" ? COLOR_DOWN : COLOR_UP; return [{ offset: 0, color }, { offset: 1, color }]; } const n = realPoints.length; const stops: { offset: number; color: string }[] = []; + const isNeutralStatus = (status: string) => status === "up" || status === "created" || status === "running"; const colorFor = (status: string) => - status === "up" ? COLOR_UP : COLOR_DOWN; + isNeutralStatus(status) ? COLOR_UP : COLOR_DOWN; stops.push({ offset: 0, color: colorFor(realPoints[0].status) }); diff --git a/web/dash0/src/routes/orgs/$org/badges.tsx b/web/dash0/src/routes/orgs/$org/badges.tsx index 133d98eb3..d0ef8a2ec 100644 --- a/web/dash0/src/routes/orgs/$org/badges.tsx +++ b/web/dash0/src/routes/orgs/$org/badges.tsx @@ -6,6 +6,7 @@ import { Download, Image, FileImage, + RefreshCw, } from "lucide-react"; import { toast } from "sonner"; import { useChecks, type Check } from "@/api/hooks"; @@ -109,6 +110,7 @@ function BadgePreview({ customLabel: string; }) { const imgRef = useRef(null); + const [cacheBust, setCacheBust] = useState(() => Date.now()); const identifier = check.slug || check.uid; const params = new URLSearchParams(); @@ -183,14 +185,23 @@ function BadgePreview({ ); // Cache-bust preview with timestamp - const previewUrl = `${badgePath}${query ? "&" : "?"}t=${Date.now()}`; + const previewUrl = `${badgePath}${query ? "&" : "?"}t=${cacheBust}`; return (
{/* Live Preview */} - + Preview +
diff --git a/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx b/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx index 8b05ed94b..ef8d07714 100644 --- a/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx +++ b/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx @@ -677,7 +677,9 @@ function CheckDetailPage() { ? "bg-green-500/10 text-green-500" : result.status === "down" ? "bg-red-500/10 text-red-500" - : "" + : result.status === "created" + ? "bg-blue-500/10 text-blue-500" + : "" } > {result.status} diff --git a/web/dash0/src/routes/orgs/$org/checks.index.tsx b/web/dash0/src/routes/orgs/$org/checks.index.tsx index 4a4572916..dcd92cee9 100644 --- a/web/dash0/src/routes/orgs/$org/checks.index.tsx +++ b/web/dash0/src/routes/orgs/$org/checks.index.tsx @@ -104,7 +104,9 @@ function StatusBadge({ status }: { status?: string | null }) { ? "bg-green-500/10 text-green-500" : label === "down" || label === "error" ? "bg-red-500/10 text-red-500" - : ""; + : label === "created" + ? "bg-blue-500/10 text-blue-500" + : ""; return ( diff --git a/web/dash0/src/routes/orgs/$org/events.tsx b/web/dash0/src/routes/orgs/$org/events.tsx index 9d0210b98..a24fe6804 100644 --- a/web/dash0/src/routes/orgs/$org/events.tsx +++ b/web/dash0/src/routes/orgs/$org/events.tsx @@ -53,6 +53,9 @@ function getEventIcon(eventType?: string) { if (eventType.startsWith("check.")) { return ; } + if (eventType === "incident.resolved") { + return ; + } if (eventType.startsWith("incident.")) { return ; }